diff --git a/.drone.yml b/.drone.yml
index 779952f..053beb0 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -9,7 +9,7 @@ platform:
steps:
- name: frontend
- image: node:24-alpine
+ image: node:22-alpine
commands:
- cd web
- npm install --network-timeout=100000
@@ -21,7 +21,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
+ - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@@ -35,7 +35,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@@ -47,7 +47,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin
@@ -61,7 +61,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
+ - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin
diff --git a/.gitignore b/.gitignore
index e943630..7ece05e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,5 +26,5 @@ logs/
*.sqlite3
# OpenAPI generated files
-internal/api/server.gen.go
-internal/model/types.gen.go
+internal/api/models.gen.go
+internal/api/server.gen.go
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 60a4243..36d7d33 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:24-alpine AS nodebuild
+FROM node:22-alpine AS nodebuild
WORKDIR /build
@@ -31,100 +31,19 @@ COPY --from=nodebuild /build/web/build/ ./web/build/
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
-# Stage 3: Prepare perl and spamass-milt
-FROM alpine:3 AS pl
-
-RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
- apk add --no-cache \
- build-base \
- libmilter-dev \
- musl-obstack-dev \
- openssl \
- openssl-dev \
- perl-app-cpanminus \
- perl-alien-libxml2 \
- perl-class-load-xs \
- perl-cpanel-json-xs \
- perl-crypt-openssl-rsa \
- perl-crypt-openssl-random \
- perl-crypt-openssl-verify \
- perl-crypt-openssl-x509 \
- perl-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
+# Stage 3: Runtime image with Postfix and all filters
FROM alpine:3
# Install all required packages
-RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
- apk add --no-cache \
+RUN apk add --no-cache \
bash \
ca-certificates \
- libmilter \
- openssl \
- perl \
- perl-alien-libxml2 \
- perl-class-load-xs \
- perl-cpanel-json-xs \
- perl-crypt-openssl-rsa \
- perl-crypt-openssl-random \
- perl-crypt-openssl-verify \
- perl-crypt-openssl-x509 \
- perl-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 \
+ opendkim \
+ opendkim-utils \
+ opendmarc \
postfix \
postfix-pcre \
- rspamd \
+ postfix-policyd-spf-perl \
spamassassin \
spamassassin-client \
supervisor \
@@ -132,8 +51,9 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
tzdata \
&& rm -rf /var/cache/apk/*
-# Copy Mail::Milter::Authentication and its dependancies
-COPY --from=pl /usr/local/ /usr/local/
+# Get test-only version of postfix-policyd-spf-perl
+ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
+RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl
# Create happydeliver user and group
RUN addgroup -g 1000 happydeliver && \
@@ -143,15 +63,12 @@ RUN addgroup -g 1000 happydeliver && \
RUN mkdir -p /etc/happydeliver \
/var/lib/happydeliver \
/var/log/happydeliver \
- /var/cache/authentication_milter \
- /var/lib/authentication_milter \
- /var/spool/postfix/authentication_milter \
- /var/spool/postfix/spamassassin \
- /var/spool/postfix/rspamd \
+ /var/spool/postfix/opendkim \
+ /var/spool/postfix/opendmarc \
+ /etc/opendkim/keys \
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
- && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
- && chown rspamd:mail /var/spool/postfix/rspamd \
- && chmod 750 /var/spool/postfix/rspamd
+ && chown -R opendkim:postfix /var/spool/postfix/opendkim \
+ && chown -R opendmarc:postfix /var/spool/postfix/opendmarc
# Copy the built application
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
@@ -159,9 +76,9 @@ RUN chmod +x /usr/local/bin/happyDeliver
# Copy configuration files
COPY docker/postfix/ /etc/postfix/
-COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
+COPY docker/opendkim/ /etc/opendkim/
+COPY docker/opendmarc/ /etc/opendmarc/
COPY docker/spamassassin/ /etc/mail/spamassassin/
-COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
COPY docker/supervisor/ /etc/supervisor/
COPY docker/entrypoint.sh /entrypoint.sh
@@ -173,21 +90,11 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080
# Default configuration
-ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
- HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
- HAPPYDELIVER_DOMAIN=happydeliver.local \
- HAPPYDELIVER_ADDRESS_PREFIX=test- \
- HAPPYDELIVER_DNS_TIMEOUT=5s \
- HAPPYDELIVER_HTTP_TIMEOUT=10s \
- HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
+ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
# Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
-# Health check
-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
- CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1
-
# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
diff --git a/README.md b/README.md
index 4010d7e..b9db23c 100644
--- a/README.md
+++ b/README.md
@@ -6,27 +6,25 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features
-- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
+- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more
- **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
-- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
+- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
- **Database Storage**: SQLite or PostgreSQL support
- **Configurable**: via environment or config file for all settings
-
-
## Quick Start
### With Docker (Recommended)
-The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application.
+The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
#### What's included in the Docker container:
- **Postfix MTA**: Receives emails on port 25
-- **authentication_milter**: Entreprise grade email authentication
+- **OpenDKIM**: DKIM signature verification
+- **OpenDMARC**: DMARC policy validation
- **SpamAssassin**: Spam scoring and analysis
-- **rspamd**: Second spam filter for cross-validated scoring
- **happyDeliver API**: REST API server on port 8080
- **SQLite Database**: Persistent storage for tests and reports
@@ -38,7 +36,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver
# Edit docker-compose.yml to set your domain
-# Change HAPPYDELIVER_DOMAIN environment variable and hostname
+# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
# Build and start
docker-compose up -d
@@ -64,86 +62,12 @@ docker run -d \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
- --hostname mail.yourdomain.com \
+ -e HOSTNAME=mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
-#### 3. Configure TLS Certificates (Optional but Recommended)
-
-To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
-
-##### Using docker-compose
-
-Add the certificate paths to your `docker-compose.yml`:
-
-```yaml
-environment:
- - POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
- - POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
-volumes:
- - /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
- - /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
-```
-
-##### Using docker run
-
-```bash
-docker run -d \
- --name happydeliver \
- -p 25:25 \
- -p 8080:8080 \
- -e HAPPYDELIVER_DOMAIN=yourdomain.com \
- -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
- -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
- --hostname mail.yourdomain.com \
- -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
- -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
- -v $(pwd)/data:/var/lib/happydeliver \
- -v $(pwd)/logs:/var/log/happydeliver \
- happydeliver:latest
-```
-
-**Notes:**
-- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
-- The private key file must be readable by the postfix user inside the container
-- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
-- If both environment variables are not set, Postfix will run without TLS support
-
-#### 4. Configure Network and DNS
-
-##### Open SMTP Port
-
-Port 25 (SMTP) must be accessible from the internet to receive test emails:
-
-```bash
-# Check if port 25 is listening
-netstat -ln | grep :25
-
-# Allow port 25 through firewall (example with ufw)
-sudo ufw allow 25/tcp
-
-# For iptables
-sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT
-```
-
-**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support.
-
-##### Configure DNS Records
-
-Point your domain to the server's IP address.
-
-```
-yourdomain.com. IN A 203.0.113.10
-yourdomain.com. IN AAAA 2001:db8::10
-```
-
-Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly.
-
-There is no need for an MX record here since the same host will serve both HTTP and SMTP.
-
-
### Manual Build
#### 1. Build
@@ -163,27 +87,10 @@ The server will start on `http://localhost:8080` by default.
#### 3. Integrate with your existing e-mail setup
-It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
+It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
-#### 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.
+Choose one of the following way to integrate happyDeliver in your existing setup:
#### Postfix LMTP Transport
@@ -201,9 +108,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
```
# Transport map - route test emails to happyDeliver LMTP server
- # Pattern: test-@yourdomain.com -> LMTP on localhost:2525
+ # Pattern: test-@yourdomain.com -> LMTP on localhost:2525
- /^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
+ /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
```
3. Append the created file to `transport_maps` in your `main.cf`:
@@ -237,7 +144,7 @@ Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
- "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
+ "email": "test-550e8400@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}
@@ -279,43 +186,24 @@ 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 A to F based on:
+The deliverability score is calculated from 0 to 10 based on:
-- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records
-- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
-- **Blacklist**: RBL/DNSBL checks
-- **Headers**: Required headers, MIME structure, Domain alignment
-- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
-- **Content**: HTML quality, links, images, unsubscribe
+- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
+- **Spam (2 pts)**: SpamAssassin score
+- **Blacklist (2 pts)**: RBL/DNSBL checks
+- **Content (2 pts)**: HTML quality, links, images, unsubscribe
+- **Headers (1 pt)**: Required headers, MIME structure
+
+**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
+
+**Ratings:**
+- 9-10: Excellent
+- 7-8.9: Good
+- 5-6.9: Fair
+- 3-4.9: Poor
+- 0-2.9: Critical
## Funding
diff --git a/api/config-models.yaml b/api/config-models.yaml
index aa2fb0e..9c3425c 100644
--- a/api/config-models.yaml
+++ b/api/config-models.yaml
@@ -1,9 +1,5 @@
-package: model
+package: api
generate:
models: true
- embedded-spec: true
-output: internal/model/types.gen.go
-output-options:
- skip-prune: true
-import-mapping:
- ./schemas.yaml: "-"
+ embedded-spec: false
+output: internal/api/models.gen.go
diff --git a/api/config-server.yaml b/api/config-server.yaml
index 347dbaf..20f8daf 100644
--- a/api/config-server.yaml
+++ b/api/config-server.yaml
@@ -1,8 +1,5 @@
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 2dbf304..83151de 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 (base32-encoded). Returns pending if no report exists, analyzed if a report is available.
+ description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
operationId: getTest
parameters:
- name: id
@@ -60,8 +60,7 @@ paths:
required: true
schema:
type: string
- pattern: '^[a-z0-9-]+$'
- description: Base32-encoded test ID (with hyphens)
+ format: uuid
responses:
'200':
description: Test status retrieved successfully
@@ -76,49 +75,6 @@ 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:
@@ -132,8 +88,7 @@ paths:
required: true
schema:
type: string
- pattern: '^[a-z0-9-]+$'
- description: Base32-encoded test ID (with hyphens)
+ format: uuid
responses:
'200':
description: Report retrieved successfully
@@ -161,8 +116,7 @@ paths:
required: true
schema:
type: string
- pattern: '^[a-z0-9-]+$'
- description: Base32-encoded test ID (with hyphens)
+ format: uuid
responses:
'200':
description: Raw email retrieved successfully
@@ -177,107 +131,6 @@ 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:
@@ -296,74 +149,386 @@ paths:
components:
schemas:
Test:
- $ref: './schemas.yaml#/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
+
TestResponse:
- $ref: './schemas.yaml#/components/schemas/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"
+
Report:
- $ref: './schemas.yaml#/components/schemas/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
+
ScoreSummary:
- $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'
+ 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"
+
AuthenticationResults:
- $ref: './schemas.yaml#/components/schemas/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'
+
AuthResult:
- $ref: './schemas.yaml#/components/schemas/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
+
ARCResult:
- $ref: './schemas.yaml#/components/schemas/ARCResult'
- IPRevResult:
- $ref: './schemas.yaml#/components/schemas/IPRevResult'
+ 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"
+
SpamAssassinResult:
- $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'
+ 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"
+
BlacklistCheck:
- $ref: './schemas.yaml#/components/schemas/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"
+
Status:
- $ref: './schemas.yaml#/components/schemas/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:
- $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'
+ 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
diff --git a/api/schemas.yaml b/api/schemas.yaml
deleted file mode 100644
index 53aa297..0000000
--- a/api/schemas.yaml
+++ /dev/null
@@ -1,1221 +0,0 @@
-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 3caf4d1..01d99f1 100644
--- a/cmd/happyDeliver/main.go
+++ b/cmd/happyDeliver/main.go
@@ -29,12 +29,13 @@ import (
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
- "git.happydns.org/happyDeliver/internal/version"
)
+const version = "0.1.0-dev"
+
func main() {
- fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
- fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
+ fmt.Println("happyDeliver - Email Deliverability Testing Platform")
+ fmt.Printf("Version: %s\n", version)
cfg, err := config.ConsolidateConfig()
if err != nil {
@@ -52,20 +53,8 @@ func main() {
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
log.Fatalf("Analyzer error: %v", err)
}
- case "backup":
- if err := app.RunBackup(cfg); err != nil {
- log.Fatalf("Backup error: %v", err)
- }
- case "restore":
- inputFile := ""
- if len(flag.Args()) >= 2 {
- inputFile = flag.Args()[1]
- }
- if err := app.RunRestore(cfg, inputFile); err != nil {
- log.Fatalf("Restore error: %v", err)
- }
case "version":
- fmt.Println(version.Version)
+ fmt.Println(version)
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
@@ -75,11 +64,9 @@ func main() {
func printUsage() {
fmt.Println("\nCommand availables:")
- fmt.Println(" happyDeliver server - Start the API server")
- fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
- fmt.Println(" happyDeliver backup - Backup database to stdout as JSON")
- fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin")
- fmt.Println(" happyDeliver version - Print version information")
+ fmt.Println(" happyDeliver server - Start the API server")
+ fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
+ fmt.Println(" happyDeliver version - Print version information")
fmt.Println("")
flag.Usage()
}
diff --git a/docker-compose.yml b/docker-compose.yml
index dc34330..87521ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,30 +1,16 @@
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: happydomain/happydeliver:latest
+ image: happydeliver:latest
container_name: happydeliver
- # Set a hostname
hostname: mail.happydeliver.local
environment:
- # Set your domain
- HAPPYDELIVER_DOMAIN: happydeliver.local
+ # Set your domain and hostname
+ DOMAIN: happydeliver.local
+ HOSTNAME: mail.happydeliver.local
ports:
# SMTP port
@@ -38,41 +24,15 @@ services:
# Log files
- ./logs:/var/log/happydeliver
- dns:
- - 172.28.0.53
restart: unless-stopped
-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"
+ healthcheck:
+ test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
volumes:
data:
logs:
-
-networks:
- default:
- ipam:
- config:
- - subnet: 172.28.0.0/24
diff --git a/docker/README.md b/docker/README.md
index 2199eeb..45cce6b 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -109,37 +109,12 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
-- `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:
+- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
+- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
+Example:
```bash
-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 ...
+docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
```
## Volumes
diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json
deleted file mode 100644
index 5db3bbc..0000000
--- a/docker/authentication_milter/authentication_milter.json
+++ /dev/null
@@ -1,75 +0,0 @@
-{
- "logtoerr" : "1",
- "error_log" : "",
- "connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock",
- "umask" : "0007",
- "runas" : "mail",
- "rungroup" : "mail",
- "authserv_id" : "__HOSTNAME__",
-
- "connect_timeout" : 30,
- "command_timeout" : 30,
- "content_timeout" : 300,
- "dns_timeout" : 10,
- "dns_retry" : 2,
-
- "handlers" : {
-
- "Sanitize" : {
- "hosts_to_remove" : [
- "__HOSTNAME__"
- ],
- "extra_auth_results_types" : [
- "X-Spam-Status",
- "X-Spam-Report",
- "X-Spam-Level",
- "X-Spam-Checker-Version"
- ]
- },
-
- "SPF" : {
- "hide_none" : 0
- },
-
- "DKIM" : {
- "hide_none" : 0,
- },
-
- "XGoogleDKIM" : {
- "hide_none" : 1,
- },
-
- "ARC" : {
- "hide_none" : 0,
- },
-
- "DMARC" : {
- "hide_none" : 0,
- "detect_list_id" : "1"
- },
-
- "BIMI" : {},
-
- "PTR" : {},
-
- "SenderID" : {
- "hide_none" : 1
- },
-
- "IPRev" : {},
-
- "Auth" : {},
-
- "AlignedFrom" : {},
-
- "LocalIP" : {},
-
- "TrustedIP" : {
- "trusted_ip_list" : []
- },
-
- "!AddID" : {},
-
- "ReturnOK" : {}
- }
-}
diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini
deleted file mode 100644
index 8097ac6..0000000
--- a/docker/authentication_milter/mail-dmarc.ini
+++ /dev/null
@@ -1,58 +0,0 @@
-; This is YOU. DMARC reports include information about the reports. Enter it here.
-[organization]
-domain = example.com
-org_name = My Company Limited
-email = admin@example.com
-extra_contact_info = http://example.com
-
-; aggregate DMARC reports need to be stored somewhere. Any database
-; with a DBI module (MySQL, SQLite, DBD, etc.) should work.
-; SQLite and MySQL are tested.
-; Default is sqlite.
-[report_store]
-backend = SQL
-;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite
-dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306
-user = authmilterusername
-pass = authmiltpassword
-
-; backend can be perl or libopendmarc
-[dmarc]
-backend = perl
-
-[dns]
-timeout = 5
-public_suffix_list = share/public_suffix_list
-
-[smtp]
-; hostname is the external FQDN of this MTA
-hostname = mx1.example.com
-cc = dmarc.copy@example.com
-
-; list IP addresses to whitelist (bypass DMARC reject/quarantine)
-; see sample whitelist in share/dmarc_whitelist
-whitelist = /path/to/etc/dmarc_whitelist
-
-; By default, we attempt to email directly to the report recipient.
-; Set these to relay via a SMTP smart host.
-smarthost = mx2.example.com
-smartuser = dmarccopyusername
-smartpass = dmarccopypassword
-
-[imap]
-server = mail.example.com
-user =
-pass =
-; the imap folder where new dmarc messages will be found
-folder = dmarc
-; the folders to store processed reports (a=aggregate, f=forensic)
-f_done = dmarc.forensic
-a_done = dmarc.aggregate
-
-[http]
-port = 8080
-
-[https]
-port = 8443
-ssl_crt =
-ssl_key =
\ No newline at end of file
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index ef45b61..445602d 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -4,42 +4,34 @@ set -e
echo "Starting happyDeliver container..."
# Get environment variables with defaults
-[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
+HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME"
echo "Domain: $HAPPYDELIVER_DOMAIN"
-# Create socket directories
-mkdir -p /var/spool/postfix/authentication_milter
-chown mail:mail /var/spool/postfix/authentication_milter
-chmod 750 /var/spool/postfix/authentication_milter
+# Create runtime directories
+mkdir -p /var/run/opendkim /var/run/opendmarc
+chown opendkim:postfix /var/run/opendkim
+chown opendmarc:postfix /var/run/opendmarc
-mkdir -p /var/spool/postfix/rspamd
-chown rspamd:mail /var/spool/postfix/rspamd
-chmod 750 /var/spool/postfix/rspamd
+# Create socket directories
+mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
+chown opendkim:postfix /var/spool/postfix/opendkim
+chown opendmarc:postfix /var/spool/postfix/opendmarc
+chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
# Create log directory
-mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
+mkdir -p /var/log/happydeliver
chown happydeliver:happydeliver /var/log/happydeliver
-chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter
# Replace placeholders in Postfix configuration
echo "Configuring Postfix..."
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
-# Add certificates to postfix
-[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
- cat <> /etc/postfix/main.cf
-smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
-smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
-smtpd_tls_security_level = may
-EOF
-}
-
-# Replace placeholders in configurations
-sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
+# Replace placeholders in OpenDMARC configuration
+sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
# Initialize Postfix aliases
if [ -f /etc/postfix/aliases ]; then
diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf
new file mode 100644
index 0000000..8fe2f8c
--- /dev/null
+++ b/docker/opendkim/opendkim.conf
@@ -0,0 +1,39 @@
+# OpenDKIM configuration for happyDeliver
+# Verifies DKIM signatures on incoming emails
+
+# Log to syslog
+Syslog yes
+SyslogSuccess yes
+LogWhy yes
+
+# Run as this user and group
+UserID opendkim:mail
+
+UMask 002
+
+# Socket for Postfix communication
+Socket unix:/var/spool/postfix/opendkim/opendkim.sock
+
+# Process ID file
+PidFile /var/run/opendkim/opendkim.pid
+
+# Operating mode - verify only (not signing)
+Mode v
+
+# Canonicalization methods
+Canonicalization relaxed/simple
+
+# DNS timeout
+DNSTimeout 5
+
+# Add header for verification results
+AlwaysAddARHeader yes
+
+# Accept unsigned mail
+On-NoSignature accept
+
+# Always add Authentication-Results header
+AlwaysAddARHeader yes
+
+# Maximum verification attempts
+MaximumSignaturesToVerify 3
diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf
new file mode 100644
index 0000000..882e11c
--- /dev/null
+++ b/docker/opendmarc/opendmarc.conf
@@ -0,0 +1,41 @@
+# OpenDMARC configuration for happyDeliver
+# Verifies DMARC policies on incoming emails
+
+# Socket for Postfix communication
+Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock
+
+# Process ID file
+PidFile /var/run/opendmarc/opendmarc.pid
+
+# Run as this user and group
+UserID opendmarc:mail
+
+UMask 002
+
+# Syslog configuration
+Syslog true
+SyslogFacility mail
+
+# Ignore authentication results from other hosts
+IgnoreAuthenticatedClients true
+
+# Accept mail even if DMARC fails (we're analyzing, not filtering)
+RejectFailures false
+
+# Trust Authentication-Results headers from localhost only
+TrustedAuthservIDs __HOSTNAME__
+
+# Add DMARC results to Authentication-Results header
+#AddAuthenticationResults true
+
+# DNS timeout
+DNSTimeout 5
+
+# History file (for reporting)
+# HistoryFile /var/spool/opendmarc/opendmarc.dat
+
+# Ignore hosts file
+# IgnoreHosts /etc/opendmarc/ignore.hosts
+
+# Public suffix list
+# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf
index 5a73fb3..913eb57 100644
--- a/docker/postfix/main.cf
+++ b/docker/postfix/main.cf
@@ -10,7 +10,7 @@ inet_interfaces = all
inet_protocols = ipv4
# Recipient settings
-mydestination = localhost.$mydomain, localhost
+mydestination = $myhostname, localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8 [::1]/128
# Relay settings - accept mail for our test domain
@@ -28,13 +28,14 @@ transport_maps = pcre:/etc/postfix/transport_maps
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
-smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
+smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock
non_smtpd_milters = $smtpd_milters
# SPF policy checking
smtpd_recipient_restrictions =
permit_mynetworks,
- reject_unauth_destination
+ reject_unauth_destination,
+ check_policy_service unix:private/policy-spf
# Logging
debug_peer_level = 2
diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf
index 9c2ac57..92976a4 100644
--- a/docker/postfix/master.cf
+++ b/docker/postfix/master.cf
@@ -2,6 +2,7 @@
# SMTP service
smtp inet n - n - - smtpd
+ -o content_filter=spamassassin
# Pickup service
pickup unix n - n 60 1 pickup
@@ -73,6 +74,10 @@ scache unix - - n - 1 scache
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
+# SPF policy service
+policy-spf unix - n n - 0 spawn
+ user=nobody argv=/usr/bin/postfix-policyd-spf-perl
+
# SpamAssassin content filter
spamassassin unix - n n - - pipe
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}
diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps
index cc1deed..49fdb98 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-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525
+/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525
diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf
deleted file mode 100644
index f3ed60c..0000000
--- a/docker/rspamd/local.d/actions.conf
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index 378b8a3..0000000
--- a/docker/rspamd/local.d/milter_headers.conf
+++ /dev/null
@@ -1,5 +0,0 @@
-# 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
deleted file mode 100644
index 485d0c9..0000000
--- a/docker/rspamd/local.d/options.inc
+++ /dev/null
@@ -1,3 +0,0 @@
-# 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
deleted file mode 100644
index 04c9a1d..0000000
--- a/docker/rspamd/local.d/worker-proxy.inc
+++ /dev/null
@@ -1,6 +0,0 @@
-# Enable rspamd milter proxy worker via Unix socket for Postfix integration
-bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
-upstream "local" {
- default = yes;
- self_scan = yes;
-}
diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf
index ce9a31c..c248ef6 100644
--- a/docker/spamassassin/local.cf
+++ b/docker/spamassassin/local.cf
@@ -48,14 +48,3 @@ rbl_timeout 5
# Don't use user-specific rules
user_scores_dsn_timeout 3
user_scores_sql_override 0
-
-# Disable Validity network rules
-dns_query_restriction deny sa-trusted.bondedsender.org
-dns_query_restriction deny sa-accredit.habeas.com
-dns_query_restriction deny bl.score.senderscore.com
-score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
-score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
-score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
-score RCVD_IN_VALIDITY_CERTIFIED 0
-score RCVD_IN_VALIDITY_RPBL 0
-score RCVD_IN_VALIDITY_SAFE 0
\ No newline at end of file
diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf
index 74f1810..1a0666e 100644
--- a/docker/supervisor/supervisord.conf
+++ b/docker/supervisor/supervisord.conf
@@ -22,26 +22,27 @@ autostart=true
autorestart=true
priority=9
-# Authentication Milter service
-[program:authentication_milter]
-command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid
+# OpenDKIM service
+[program:opendkim]
+command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
autostart=true
autorestart=true
priority=10
-stdout_logfile=/var/log/happydeliver/authentication_milter.log
-stderr_logfile=/var/log/happydeliver/authentication_milter.log
-user=mail
+stdout_logfile=/var/log/happydeliver/opendkim.log
+stderr_logfile=/var/log/happydeliver/opendkim_error.log
+user=opendkim
group=mail
-# rspamd spam filter
-[program:rspamd]
-command=/usr/bin/rspamd -f -u rspamd -g mail
+# OpenDMARC service
+[program:opendmarc]
+command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
autostart=true
autorestart=true
priority=11
-stdout_logfile=/var/log/happydeliver/rspamd.log
-stderr_logfile=/var/log/happydeliver/rspamd_error.log
-user=root
+stdout_logfile=/var/log/happydeliver/opendmarc.log
+stderr_logfile=/var/log/happydeliver/opendmarc_error.log
+user=opendmarc
+group=mail
# SpamAssassin daemon
[program:spamd]
@@ -53,18 +54,6 @@ stdout_logfile=/var/log/happydeliver/spamd.log
stderr_logfile=/var/log/happydeliver/spamd_error.log
user=root
-# SpamAssassin milter
-[program:spamass_milter]
-command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m
-autostart=true
-autorestart=true
-priority=7
-stdout_logfile=/var/log/happydeliver/spamass-milter.log
-stderr_logfile=/var/log/happydeliver/spamass-milter_error.log
-user=mail
-group=mail
-umask=007
-
# Postfix service
[program:postfix]
command=/usr/sbin/postfix start-fg
diff --git a/generate.go b/generate.go
index 324c52c..d1ee5ab 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/schemas.yaml
+//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
diff --git a/go.mod b/go.mod
index a975215..7604b07 100644
--- a/go.mod
+++ b/go.mod
@@ -1,42 +1,38 @@
module git.happydns.org/happyDeliver
-go 1.25.0
+go 1.24.6
require (
- github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0
- github.com/getkin/kin-openapi v0.138.0
- github.com/gin-gonic/gin v1.12.0
+ github.com/getkin/kin-openapi v0.132.0
+ github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
- github.com/oapi-codegen/runtime v1.4.0
- golang.org/x/net v0.54.0
+ github.com/oapi-codegen/runtime v1.1.2
+ golang.org/x/net v0.46.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
- gorm.io/gorm v1.31.1
+ gorm.io/gorm v1.31.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
- github.com/bytedance/gopkg v0.1.3 // indirect
- github.com/bytedance/sonic v1.15.0 // indirect
- github.com/bytedance/sonic/loader v0.5.0 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/bytedance/sonic v1.14.0 // indirect
+ github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
- github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
- github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
- github.com/go-openapi/jsonpointer v0.22.4 // indirect
- github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.30.1 // indirect
- github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/go-playground/validator/v10 v10.27.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.8.0 // indirect
+ github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -44,38 +40,34 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mailru/easyjson v0.9.1 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-sqlite3 v1.14.33 // indirect
+ github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
- github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect
- github.com/oasdiff/yaml v0.0.9 // indirect
- github.com/oasdiff/yaml3 v0.0.12 // indirect
+ github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
+ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
+ github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
- github.com/quic-go/qpack v0.6.0 // indirect
- github.com/quic-go/quic-go v0.59.0 // indirect
- github.com/redis/go-redis/v9 v9.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/quic-go/qpack v0.5.1 // indirect
+ github.com/quic-go/quic-go v0.54.1 // indirect
+ github.com/speakeasy-api/jsonpath v0.6.0 // indirect
+ github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
- github.com/ugorji/go/codec v1.3.1 // indirect
+ github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
- github.com/woodsbury/decimal128 v1.4.0 // indirect
- go.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
+ go.uber.org/mock v0.5.0 // indirect
+ golang.org/x/arch v0.20.0 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.28.0 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
+ golang.org/x/tools v0.37.0 // indirect
+ google.golang.org/protobuf v1.36.9 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index f4c8d28..bc46bc0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,34 +1,19 @@
-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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
-github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
-github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
-github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
-github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
-github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
-github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
-github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
-github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
-github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
+github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
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=
@@ -39,35 +24,33 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
-github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
-github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
-github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
+github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
-github.com/gin-gonic/gin v1.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/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
-github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
-github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
-github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -93,8 +76,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
-github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
+github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -117,12 +100,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
-github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
-github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -133,14 +116,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.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/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/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=
@@ -157,69 +140,52 @@ 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/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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
+github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
-github.com/speakeasy-api/jsonpath v0.6.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/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/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
-github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
-github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
-github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-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=
+go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
+go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
+golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -227,13 +193,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.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/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -249,21 +215,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -276,8 +242,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
+google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -299,5 +265,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
-gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
+gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index de2d5df..79d839e 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -31,34 +31,21 @@ 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, analyzer EmailAnalyzer) *APIHandler {
+func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
- analyzer: analyzer,
startTime: time.Now(),
}
}
@@ -69,99 +56,79 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
- // Convert UUID to base32 string for the API response
- base32ID := utils.UUIDToBase32(testID)
-
- // Generate test email address using Base32-encoded UUID
+ // Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
- base32ID,
+ testID.String(),
h.config.Email.Domain,
)
// Return response
- c.JSON(http.StatusCreated, model.TestResponse{
- Id: base32ID,
+ c.JSON(http.StatusCreated, TestResponse{
+ Id: testID,
Email: openapi_types.Email(email),
- Status: model.TestResponseStatusPending,
- Message: utils.PtrTo("Send your test email to the given address"),
+ Status: TestResponseStatusPending,
+ Message: stringPtr("Send your test email to the address above"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
-func (h *APIHandler) GetTest(c *gin.Context, id string) {
- // Convert base32 ID to UUID
- testUUID, err := utils.Base32ToUUID(id)
- if err != nil {
- c.JSON(http.StatusBadRequest, model.Error{
- Error: "invalid_id",
- Message: "Invalid test ID format",
- Details: utils.PtrTo(err.Error()),
- })
- return
- }
-
+func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
// Check if a report exists for this test ID
- reportExists, err := h.storage.ReportExists(testUUID)
+ reportExists, err := h.storage.ReportExists(id)
if err != nil {
- c.JSON(http.StatusInternalServerError, model.Error{
+ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to check test status",
- Details: utils.PtrTo(err.Error()),
+ Details: stringPtr(err.Error()),
})
return
}
// Determine status based on report existence
- var apiStatus model.TestStatus
+ var apiStatus TestStatus
if reportExists {
- apiStatus = model.TestStatusAnalyzed
+ apiStatus = TestStatusAnalyzed
} else {
- apiStatus = model.TestStatusPending
+ apiStatus = TestStatusPending
}
- // Generate test email address using Base32-encoded UUID
+ // Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
- id,
+ id.String(),
h.config.Email.Domain,
)
- c.JSON(http.StatusOK, model.Test{
- Id: id,
- Email: openapi_types.Email(email),
- Status: apiStatus,
+ // 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,
})
}
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
-func (h *APIHandler) GetReport(c *gin.Context, id string) {
- // Convert base32 ID to UUID
- testUUID, err := utils.Base32ToUUID(id)
- if err != nil {
- c.JSON(http.StatusBadRequest, model.Error{
- Error: "invalid_id",
- Message: "Invalid test ID format",
- Details: utils.PtrTo(err.Error()),
- })
- return
- }
-
- reportJSON, _, err := h.storage.GetReport(testUUID)
+func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
+ reportJSON, _, err := h.storage.GetReport(id)
if err != nil {
if err == storage.ErrNotFound {
- c.JSON(http.StatusNotFound, model.Error{
+ c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Report not found",
})
return
}
- c.JSON(http.StatusInternalServerError, model.Error{
+ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve report",
- Details: utils.PtrTo(err.Error()),
+ Details: stringPtr(err.Error()),
})
return
}
@@ -172,31 +139,20 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
-func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
- // Convert base32 ID to UUID
- testUUID, err := utils.Base32ToUUID(id)
- if err != nil {
- c.JSON(http.StatusBadRequest, model.Error{
- Error: "invalid_id",
- Message: "Invalid test ID format",
- Details: utils.PtrTo(err.Error()),
- })
- return
- }
-
- _, rawEmail, err := h.storage.GetReport(testUUID)
+func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
+ _, rawEmail, err := h.storage.GetReport(id)
if err != nil {
if err == storage.ErrNotFound {
- c.JSON(http.StatusNotFound, model.Error{
+ c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
- c.JSON(http.StatusInternalServerError, model.Error{
+ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
- Details: utils.PtrTo(err.Error()),
+ Details: stringPtr(err.Error()),
})
return
}
@@ -204,63 +160,6 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
c.Data(http.StatusOK, "text/plain", rawEmail)
}
-// ReanalyzeReport re-analyzes an existing email and regenerates the report
-// (POST /report/{id}/reanalyze)
-func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
- // Convert base32 ID to UUID
- testUUID, err := utils.Base32ToUUID(id)
- if err != nil {
- c.JSON(http.StatusBadRequest, 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) {
@@ -268,24 +167,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 := model.StatusComponentsDatabaseUp
+ dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
- dbStatus = model.StatusComponentsDatabaseDown
+ dbStatus = StatusComponentsDatabaseDown
}
// Determine overall status
- overallStatus := model.Healthy
- if dbStatus == model.StatusComponentsDatabaseDown {
- overallStatus = model.Unhealthy
+ overallStatus := Healthy
+ if dbStatus == StatusComponentsDatabaseDown {
+ overallStatus = Unhealthy
}
- mtaStatus := model.StatusComponentsMtaUp
- c.JSON(http.StatusOK, model.Status{
+ mtaStatus := StatusComponentsMtaUp
+ c.JSON(http.StatusOK, Status{
Status: overallStatus,
- Version: version.Version,
+ Version: "0.1.0-dev",
Components: &struct {
- Database *model.StatusComponentsDatabase `json:"database,omitempty"`
- Mta *model.StatusComponentsMta `json:"mta,omitempty"`
+ Database *StatusComponentsDatabase `json:"database,omitempty"`
+ Mta *StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@@ -293,133 +192,3 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
Uptime: &uptime,
})
}
-
-// TestDomain performs synchronous domain analysis
-// (POST /domain)
-func (h *APIHandler) TestDomain(c *gin.Context) {
- var request 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/utils/ptr.go b/internal/api/helpers.go
similarity index 91%
rename from internal/utils/ptr.go
rename to internal/api/helpers.go
index 748d6ba..cce306a 100644
--- a/internal/utils/ptr.go
+++ b/internal/api/helpers.go
@@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2026 happyDomain
+// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@@ -19,7 +19,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package utils
+package api
+
+func stringPtr(s string) *string {
+ return &s
+}
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {
diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go
index c704c56..2cccf1b 100644
--- a/internal/app/cli_analyzer.go
+++ b/internal/app/cli_analyzer.go
@@ -31,6 +31,7 @@ import (
"github.com/google/uuid"
+ "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
@@ -86,552 +87,57 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
// outputHumanReadable outputs a human-readable summary
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
- report := result.Report
-
- // Header with overall score
+ // Header
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
fmt.Fprintln(writer, strings.Repeat("=", 70))
- fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade)
- fmt.Fprintf(writer, "Test ID: %s\n", report.TestId)
- fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST"))
- // Score Summary
- if report.Summary != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "SCORE BREAKDOWN")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
+ // Score summary
+ summary := emailAnalyzer.GetScoreSummaryText(result)
+ fmt.Fprintln(writer, summary)
- summary := report.Summary
- fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n",
- summary.DnsScore, summary.DnsGrade)
- fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n",
- summary.AuthenticationScore, summary.AuthenticationGrade)
- fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n",
- summary.BlacklistScore, summary.BlacklistGrade)
- fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n",
- summary.HeaderScore, summary.HeaderGrade)
- fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n",
- summary.SpamScore, summary.SpamGrade)
- fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n",
- summary.ContentScore, summary.ContentGrade)
+ // Detailed checks
+ fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
+ fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
+ fmt.Fprintln(writer, strings.Repeat("-", 70))
+
+ // Group checks by category
+ categories := make(map[api.CheckCategory][]api.Check)
+ for _, check := range result.Report.Checks {
+ categories[check.Category] = append(categories[check.Category], check)
}
- // DNS Results
- if report.DnsResults != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "DNS CONFIGURATION")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
+ // Print checks by category
+ categoryOrder := []api.CheckCategory{
+ api.Authentication,
+ api.Dns,
+ api.Blacklist,
+ api.Content,
+ api.Headers,
+ }
- dns := report.DnsResults
- fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain)
- if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain {
- fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain)
+ for _, category := range categoryOrder {
+ checks, ok := categories[category]
+ if !ok || len(checks) == 0 {
+ continue
}
- // MX Records
- if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
- fmt.Fprintln(writer, "\n MX Records (From Domain):")
- for _, mx := range *dns.FromMxRecords {
- status := "✓"
- if !mx.Valid {
- status = "✗"
- }
- fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
- if mx.Error != nil {
- fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
- }
- fmt.Fprintln(writer)
+ fmt.Fprintf(writer, "\n%s:\n", category)
+ for _, check := range checks {
+ statusSymbol := "✓"
+ if check.Status == api.CheckStatusFail {
+ statusSymbol = "✗"
+ } else if check.Status == api.CheckStatusWarn {
+ statusSymbol = "⚠"
}
- }
- // SPF Records
- if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 {
- fmt.Fprintln(writer, "\n SPF Records:")
- for _, spf := range *dns.SpfRecords {
- status := "✓"
- if !spf.Valid {
- status = "✗"
- }
- fmt.Fprintf(writer, " %s ", status)
- if spf.Domain != nil {
- fmt.Fprintf(writer, "Domain: %s", *spf.Domain)
- }
- if spf.AllQualifier != nil {
- fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier)
- }
- fmt.Fprintln(writer)
- if spf.Record != nil {
- fmt.Fprintf(writer, " %s\n", *spf.Record)
- }
- if spf.Error != nil {
- fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error)
- }
- }
- }
-
- // DKIM Records
- if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 {
- fmt.Fprintln(writer, "\n DKIM Records:")
- for _, dkim := range *dns.DkimRecords {
- status := "✓"
- if !dkim.Valid {
- status = "✗"
- }
- fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain)
- if dkim.Record != nil {
- fmt.Fprintf(writer, " %s\n", *dkim.Record)
- }
- if dkim.Error != nil {
- fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error)
- }
- }
- }
-
- // DMARC Record
- if dns.DmarcRecord != nil {
- fmt.Fprintln(writer, "\n DMARC Record:")
- status := "✓"
- if !dns.DmarcRecord.Valid {
- status = "✗"
- }
- fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid)
- if dns.DmarcRecord.Policy != nil {
- fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy)
- }
- if dns.DmarcRecord.SubdomainPolicy != nil {
- fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
- }
- 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)
+ fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
+ if check.Advice != nil && *check.Advice != "" {
+ fmt.Fprintf(writer, " → %s\n", *check.Advice)
}
}
}
- // Authentication Results
- if report.Authentication != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "AUTHENTICATION RESULTS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- auth := report.Authentication
-
- // SPF
- if auth.Spf != nil {
- fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result)))
- if auth.Spf.Domain != nil {
- fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain)
- }
- if auth.Spf.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details)
- }
- fmt.Fprintln(writer)
- }
-
- // DKIM
- if auth.Dkim != nil && len(*auth.Dkim) > 0 {
- fmt.Fprintln(writer, "\n DKIM:")
- for i, dkim := range *auth.Dkim {
- fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result)))
- if dkim.Domain != nil {
- fmt.Fprintf(writer, " (domain: %s", *dkim.Domain)
- if dkim.Selector != nil {
- fmt.Fprintf(writer, ", selector: %s", *dkim.Selector)
- }
- fmt.Fprintf(writer, ")")
- }
- if dkim.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *dkim.Details)
- }
- fmt.Fprintln(writer)
- }
- }
-
- // DMARC
- if auth.Dmarc != nil {
- fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result)))
- if auth.Dmarc.Domain != nil {
- fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain)
- }
- if auth.Dmarc.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details)
- }
- fmt.Fprintln(writer)
- }
-
- // ARC
- if auth.Arc != nil {
- fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result)))
- if auth.Arc.ChainLength != nil {
- fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength)
- }
- if auth.Arc.ChainValid != nil {
- fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid)
- }
- if auth.Arc.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details)
- }
- fmt.Fprintln(writer)
- }
-
- // BIMI
- if auth.Bimi != nil {
- fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result)))
- if auth.Bimi.Domain != nil {
- fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain)
- }
- if auth.Bimi.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details)
- }
- fmt.Fprintln(writer)
- }
-
- // IP Reverse
- if auth.Iprev != nil {
- fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result)))
- if auth.Iprev.Ip != nil {
- fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip)
- if auth.Iprev.Hostname != nil {
- fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname)
- }
- fmt.Fprintf(writer, ")")
- }
- if auth.Iprev.Details != nil {
- fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details)
- }
- fmt.Fprintln(writer)
- }
- }
-
- // Blacklist Results
- if report.Blacklists != nil && len(*report.Blacklists) > 0 {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "BLACKLIST CHECKS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- totalChecks := 0
- totalListed := 0
- for ip, checks := range *report.Blacklists {
- totalChecks += len(checks)
- fmt.Fprintf(writer, "\n IP Address: %s\n", ip)
- for _, check := range checks {
- status := "✓"
- if check.Listed {
- status = "✗"
- totalListed++
- }
- fmt.Fprintf(writer, " %s %s", status, check.Rbl)
- if check.Listed {
- fmt.Fprintf(writer, " - LISTED")
- if check.Response != nil {
- fmt.Fprintf(writer, " (%s)", *check.Response)
- }
- } else {
- fmt.Fprintf(writer, " - OK")
- }
- fmt.Fprintln(writer)
- if check.Error != nil {
- fmt.Fprintf(writer, " ERROR: %s\n", *check.Error)
- }
- }
- }
- fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks)
- }
-
- // Header Analysis
- if report.HeaderAnalysis != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "HEADER ANALYSIS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- header := report.HeaderAnalysis
-
- // Domain Alignment
- if header.DomainAlignment != nil {
- fmt.Fprintln(writer, "\n Domain Alignment:")
- align := header.DomainAlignment
- if align.FromDomain != nil {
- fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain)
- if align.FromOrgDomain != nil {
- fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain)
- }
- fmt.Fprintln(writer)
- }
- if align.ReturnPathDomain != nil {
- fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain)
- if align.ReturnPathOrgDomain != nil {
- fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain)
- }
- fmt.Fprintln(writer)
- }
- if align.Aligned != nil {
- fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned)
- }
- if align.RelaxedAligned != nil {
- fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned)
- }
- if align.Issues != nil && len(*align.Issues) > 0 {
- fmt.Fprintln(writer, " Issues:")
- for _, issue := range *align.Issues {
- fmt.Fprintf(writer, " - %s\n", issue)
- }
- }
- }
-
- // Required/Important Headers
- if header.Headers != nil {
- fmt.Fprintln(writer, "\n Standard Headers:")
- importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"}
- for _, hdrName := range importantHeaders {
- if hdr, ok := (*header.Headers)[hdrName]; ok {
- status := "✗"
- if hdr.Present {
- status = "✓"
- }
- fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName))
- if hdr.Present {
- if hdr.Valid != nil && !*hdr.Valid {
- fmt.Fprintf(writer, "INVALID")
- } else {
- fmt.Fprintf(writer, "OK")
- }
- if hdr.Importance != nil {
- fmt.Fprintf(writer, " [%s]", *hdr.Importance)
- }
- } else {
- fmt.Fprintf(writer, "MISSING")
- }
- fmt.Fprintln(writer)
- if hdr.Issues != nil && len(*hdr.Issues) > 0 {
- for _, issue := range *hdr.Issues {
- fmt.Fprintf(writer, " - %s\n", issue)
- }
- }
- }
- }
- }
-
- // Header Issues
- if header.Issues != nil && len(*header.Issues) > 0 {
- fmt.Fprintln(writer, "\n Header Issues:")
- for _, issue := range *header.Issues {
- fmt.Fprintf(writer, " [%s] %s: %s\n",
- strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message)
- if issue.Advice != nil {
- fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
- }
- }
- }
-
- // Received Chain
- if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 {
- fmt.Fprintln(writer, "\n Email Path (Received Chain):")
- for i, hop := range *header.ReceivedChain {
- fmt.Fprintf(writer, " [%d] ", i+1)
- if hop.From != nil {
- fmt.Fprintf(writer, "%s", *hop.From)
- if hop.Ip != nil {
- fmt.Fprintf(writer, " (%s)", *hop.Ip)
- }
- }
- if hop.By != nil {
- fmt.Fprintf(writer, " -> %s", *hop.By)
- }
- fmt.Fprintln(writer)
- if hop.Timestamp != nil {
- fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST"))
- }
- }
- }
- }
-
- // SpamAssassin Results
- if report.Spamassassin != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- sa := report.Spamassassin
- fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore)
- if sa.IsSpam {
- fmt.Fprintf(writer, " (SPAM)")
- } else {
- fmt.Fprintf(writer, " (HAM)")
- }
- fmt.Fprintln(writer)
-
- if sa.Version != nil {
- fmt.Fprintf(writer, " Version: %s\n", *sa.Version)
- }
-
- if len(sa.TestDetails) > 0 {
- fmt.Fprintln(writer, "\n Triggered Tests:")
- for _, test := range sa.TestDetails {
- scoreStr := "+"
- if test.Score < 0 {
- scoreStr = ""
- }
- fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name)
- if test.Description != nil {
- fmt.Fprintf(writer, "\n %s", *test.Description)
- }
- fmt.Fprintln(writer)
- }
- }
- }
-
- // Content Analysis
- if report.ContentAnalysis != nil {
- fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
- fmt.Fprintln(writer, "CONTENT ANALYSIS")
- fmt.Fprintln(writer, strings.Repeat("-", 70))
-
- content := report.ContentAnalysis
-
- // Basic content info
- fmt.Fprintln(writer, "\n Content Structure:")
- if content.HasPlaintext != nil {
- fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext)
- }
- if content.HasHtml != nil {
- fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml)
- }
- if content.TextToImageRatio != nil {
- fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio)
- }
-
- // Unsubscribe
- if content.HasUnsubscribeLink != nil {
- fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink)
- if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 {
- fmt.Fprintf(writer, " Unsubscribe Methods: ")
- for i, method := range *content.UnsubscribeMethods {
- if i > 0 {
- fmt.Fprintf(writer, ", ")
- }
- fmt.Fprintf(writer, "%s", method)
- }
- fmt.Fprintln(writer)
- }
- }
-
- // Links
- if content.Links != nil && len(*content.Links) > 0 {
- fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links))
- for _, link := range *content.Links {
- status := ""
- switch link.Status {
- case "valid":
- status = "✓"
- case "broken":
- status = "✗"
- case "suspicious":
- status = "⚠"
- case "redirected":
- status = "→"
- case "timeout":
- status = "⏱"
- }
- fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url)
- if link.HttpCode != nil {
- fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode)
- }
- fmt.Fprintln(writer)
- if link.RedirectChain != nil && len(*link.RedirectChain) > 0 {
- fmt.Fprintln(writer, " Redirect chain:")
- for _, url := range *link.RedirectChain {
- fmt.Fprintf(writer, " -> %s\n", url)
- }
- }
- }
- }
-
- // Images
- if content.Images != nil && len(*content.Images) > 0 {
- fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images))
- missingAlt := 0
- trackingPixels := 0
- for _, img := range *content.Images {
- if !img.HasAlt {
- missingAlt++
- }
- if img.IsTrackingPixel != nil && *img.IsTrackingPixel {
- trackingPixels++
- }
- }
- fmt.Fprintf(writer, " Images with ALT text: %d/%d\n",
- len(*content.Images)-missingAlt, len(*content.Images))
- if trackingPixels > 0 {
- fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels)
- }
- }
-
- // HTML Issues
- if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 {
- fmt.Fprintln(writer, "\n Content Issues:")
- for _, issue := range *content.HtmlIssues {
- fmt.Fprintf(writer, " [%s] %s: %s\n",
- strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message)
- if issue.Location != nil {
- fmt.Fprintf(writer, " Location: %s\n", *issue.Location)
- }
- if issue.Advice != nil {
- fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
- }
- }
- }
- }
-
- // Footer
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
- fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n")
- fmt.Fprintln(writer, strings.Repeat("=", 70))
-
return nil
}
diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go
deleted file mode 100644
index 4b01fbb..0000000
--- a/internal/app/cli_backup.go
+++ /dev/null
@@ -1,156 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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 7149f45..332516b 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -25,16 +25,13 @@ import (
"context"
"log"
"os"
- "time"
- ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp"
"git.happydns.org/happyDeliver/internal/storage"
- "git.happydns.org/happyDeliver/pkg/analyzer"
"git.happydns.org/happyDeliver/web"
)
@@ -66,11 +63,8 @@ func RunServer(cfg *config.Config) error {
}
}()
- // Create analyzer adapter for API
- analyzerAdapter := analyzer.NewAPIAdapter(cfg)
-
// Create API handler
- handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
+ handler := api.NewAPIHandler(store, cfg)
// Set up Gin router
if os.Getenv("GIN_MODE") == "" {
@@ -78,30 +72,8 @@ func RunServer(cfg *config.Config) error {
}
router := gin.Default()
- apiGroup := router.Group("/api")
-
- if cfg.RateLimit > 0 {
- // Set up rate limiting (2x to handle burst)
- rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
- Rate: 2 * time.Second,
- Limit: 2 * cfg.RateLimit,
- })
- rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{
- ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
- c.JSON(429, gin.H{
- "error": "rate_limit_exceeded",
- "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(),
- })
- },
- KeyFunc: func(c *gin.Context) string {
- return c.ClientIP()
- },
- })
-
- apiGroup.Use(rateLimiter)
- }
-
// Register API routes
+ apiGroup := router.Group("/api")
api.RegisterHandlers(apiGroup, handler)
web.DeclareRoutes(cfg, router)
diff --git a/internal/config/cli.go b/internal/config/cli.go
index fcc914f..93c18ce 100644
--- a/internal/config/cli.go
+++ b/internal/config/cli.go
@@ -34,17 +34,10 @@ 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 b264994..510aaa9 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -25,7 +25,6 @@ import (
"flag"
"fmt"
"log"
- "net/url"
"os"
"path"
"strings"
@@ -34,11 +33,6 @@ 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
@@ -47,10 +41,6 @@ type Config struct {
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
- RateLimit uint // API rate limit (requests per second per IP)
- SurveyURL url.URL // URL for user feedback survey
- CustomLogoURL string // URL for custom logo image in the web UI
- DisableTestList bool // Disable the public test listing endpoint
}
// DatabaseConfig contains database connection settings
@@ -64,17 +54,13 @@ 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
- 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)
+ DNSTimeout time.Duration
+ HTTPTimeout time.Duration
+ RBLs []string
}
// DefaultConfig returns a configuration with sensible defaults
@@ -83,7 +69,6 @@ func DefaultConfig() *Config {
DevProxy: "",
Bind: ":8080",
ReportRetention: 0, // Keep reports forever by default
- RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default)
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
@@ -92,14 +77,11 @@ 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 97c8d71..9461632 100644
--- a/internal/config/custom.go
+++ b/internal/config/custom.go
@@ -23,7 +23,6 @@ package config
import (
"fmt"
- "net/url"
"strings"
)
@@ -44,25 +43,3 @@ func (i *StringArray) Set(value string) error {
return nil
}
-
-type URL struct {
- URL *url.URL
-}
-
-func (i *URL) String() string {
- if i.URL != nil {
- return i.URL.String()
- } else {
- return ""
- }
-}
-
-func (i *URL) Set(value string) error {
- u, err := url.Parse(value)
- if err != nil {
- return err
- }
-
- *i.URL = *u
- return nil
-}
diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go
index a9b36b9..1d9a720 100644
--- a/internal/lmtp/server.go
+++ b/internal/lmtp/server.go
@@ -92,10 +92,6 @@ func (s *Session) Data(r io.Reader) error {
log.Printf("LMTP: Received %d bytes", len(emailData))
- // Prepend Return-Path header from envelope sender
- returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from)
- emailData = append([]byte(returnPath), emailData...)
-
// Process email for each recipient
// LMTP requires per-recipient status, but go-smtp handles this internally
for _, recipient := range s.recipients {
diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go
index f06f535..1132b54 100644
--- a/internal/receiver/receiver.go
+++ b/internal/receiver/receiver.go
@@ -22,7 +22,6 @@
package receiver
import (
- "encoding/base32"
"encoding/json"
"fmt"
"io"
@@ -96,18 +95,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return fmt.Errorf("failed to analyze email: %w", err)
}
- log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
-
- // 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)
- }
- }
+ log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
@@ -124,34 +112,8 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return nil
}
-// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
-// Hyphens are ignored during decoding
-func base32ToUUID(encoded string) (uuid.UUID, error) {
- // Remove hyphens for decoding
- encoded = strings.ReplaceAll(encoded, "-", "")
-
- // Convert to uppercase for Base32 decoding
- encoded = strings.ToUpper(encoded)
-
- // Decode from Base32
- decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
- if err != nil {
- return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
- }
-
- // Ensure we have exactly 16 bytes for UUID
- if len(decoded) != 16 {
- return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
- }
-
- // Convert bytes to UUID
- var id uuid.UUID
- copy(id[:], decoded)
- return id, nil
-}
-
// extractTestID extracts the UUID from the test email address
-// Expected format: test-@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, "<>")
@@ -171,10 +133,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
- // Decode Base32 to UUID
- testID, err := base32ToUUID(uuidStr)
+ // Parse UUID
+ testID, err := uuid.Parse(uuidStr)
if err != nil {
- return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
+ return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
}
return testID, nil
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 86605df..7c27279 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -30,9 +30,6 @@ 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 (
@@ -46,9 +43,7 @@ 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
@@ -112,7 +107,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
var dbReport Report
- if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil {
+ if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrNotFound
}
@@ -122,18 +117,6 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
return dbReport.ReportJSON, dbReport.RawEmail, nil
}
-// UpdateReport updates the report JSON for an existing test ID
-func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
- result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
- if result.Error != nil {
- return fmt.Errorf("failed to update report: %w", result.Error)
- }
- if result.RowsAffected == 0 {
- return ErrNotFound
- }
- return nil
-}
-
// DeleteOldReports deletes reports older than the specified time
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
@@ -143,72 +126,6 @@ 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()
@@ -217,33 +134,3 @@ func (s *DBStorage) Close() error {
}
return sqlDB.Close()
}
-
-// GetAllReports retrieves all reports from the database
-func GetAllReports(s Storage) ([]Report, error) {
- dbStorage, ok := s.(*DBStorage)
- if !ok {
- return nil, fmt.Errorf("storage type does not support GetAllReports")
- }
-
- var reports []Report
- if err := dbStorage.db.Find(&reports).Error; err != nil {
- return nil, fmt.Errorf("failed to retrieve reports: %w", err)
- }
-
- return reports, nil
-}
-
-// CreateReportFromBackup creates a report from backup data, preserving timestamps
-func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
- dbStorage, ok := s.(*DBStorage)
- if !ok {
- return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
- }
-
- // Use Create to insert the report with all fields including timestamps
- if err := dbStorage.db.Create(report).Error; err != nil {
- return nil, fmt.Errorf("failed to create report from backup: %w", err)
- }
-
- return report, nil
-}
diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go
deleted file mode 100644
index ebbbbdf..0000000
--- a/internal/utils/uuid.go
+++ /dev/null
@@ -1,75 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index a46c79f..0000000
--- a/internal/version/version.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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 5f57df3..3588280 100644
--- a/pkg/analyzer/analyzer.go
+++ b/pkg/analyzer/analyzer.go
@@ -23,12 +23,11 @@ package analyzer
import (
"bytes"
- "encoding/json"
"fmt"
"github.com/google/uuid"
- "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
@@ -41,13 +40,9 @@ 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{
@@ -59,7 +54,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
- Report *model.Report
+ Report *api.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@@ -83,68 +78,10 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
}, nil
}
-// APIAdapter adapts the EmailAnalyzer to work with the API package
-// This adapter implements the interface expected by the API handler
-type APIAdapter struct {
- analyzer *EmailAnalyzer
-}
-
-// NewAPIAdapter creates a new API adapter for the email analyzer
-func NewAPIAdapter(cfg *config.Config) *APIAdapter {
- return &APIAdapter{
- analyzer: NewEmailAnalyzer(cfg),
- }
-}
-
-// AnalyzeEmailBytes performs analysis and returns JSON bytes directly
-func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) {
- result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID)
- if err != nil {
- return nil, err
- }
-
- // Marshal report to JSON
- reportJSON, err := json.Marshal(result.Report)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal report: %w", err)
- }
-
- return reportJSON, nil
-}
-
-// AnalyzeDomain performs DNS analysis for a domain and returns the results
-func (a *APIAdapter) AnalyzeDomain(domain string) (*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
+// 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)
}
diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go
index bd8880d..d6fd600 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/model"
+ "git.happydns.org/happyDeliver/internal/api"
)
// AuthenticationAnalyzer analyzes email authentication results
-type AuthenticationAnalyzer struct {
- receiverHostname string
-}
+type AuthenticationAnalyzer struct{}
// NewAuthenticationAnalyzer creates a new authentication analyzer
-func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
- return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
+func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
+ return &AuthenticationAnalyzer{}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
-func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
- results := &model.AuthenticationResults{}
+func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
+ results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
- authHeaders := email.GetAuthenticationResults(a.receiverHostname)
+ authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@@ -52,6 +52,13 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
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)
@@ -65,7 +72,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
// 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 *model.AuthenticationResults) {
+func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@@ -91,7 +98,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
- dkimList := []model.AuthResult{*dkimResult}
+ dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@@ -119,67 +126,382 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.Arc = a.parseARCResult(part)
}
}
-
- // Parse IPRev
- if strings.HasPrefix(part, "iprev=") {
- if results.Iprev == nil {
- results.Iprev = a.parseIPRevResult(part)
- }
- }
-
- // Parse x-google-dkim
- if strings.HasPrefix(part, "x-google-dkim=") {
- if results.XGoogleDkim == nil {
- results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
- }
- }
-
- // Parse x-aligned-from
- if strings.HasPrefix(part, "x-aligned-from=") {
- if results.XAlignedFrom == nil {
- results.XAlignedFrom = a.parseXAlignedFromResult(part)
- }
- }
}
}
-// CalculateAuthenticationScore calculates the authentication score from auth results
-// Returns a score from 0-100 where higher is better
-func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
- if results == nil {
- return 0, ""
+// parseSPFResult parses SPF result from Authentication-Results
+// Example: spf=pass smtp.mailfrom=sender@example.com
+func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
+ result := &api.AuthResult{}
+
+ // Extract result (pass, fail, etc.)
+ re := regexp.MustCompile(`spf=(\w+)`)
+ if matches := re.FindStringSubmatch(part); len(matches) > 1 {
+ resultStr := strings.ToLower(matches[1])
+ result.Result = api.AuthResultResult(resultStr)
}
- score := 0
-
- // 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
+ // 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
+ }
}
- // 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
+ // 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 score, ScoreToGrade(score)
+ return result
+}
+
+// parseDKIMResult parses DKIM result from Authentication-Results
+// Example: dkim=pass header.d=example.com header.s=selector1
+func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
+ result := &api.AuthResult{}
+
+ // Extract result (pass, fail, etc.)
+ re := regexp.MustCompile(`dkim=(\w+)`)
+ if matches := re.FindStringSubmatch(part); len(matches) > 1 {
+ resultStr := strings.ToLower(matches[1])
+ result.Result = api.AuthResultResult(resultStr)
+ }
+
+ // Extract domain (header.d or d)
+ domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
+ if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
+ domain := matches[1]
+ result.Domain = &domain
+ }
+
+ // Extract selector (header.s or s)
+ selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
+ if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
+ selector := matches[1]
+ result.Selector = &selector
+ }
+
+ // Extract details
+ if idx := strings.Index(part, "("); idx != -1 {
+ endIdx := strings.Index(part[idx:], ")")
+ if endIdx != -1 {
+ details := strings.TrimSpace(part[idx+1 : idx+endIdx])
+ result.Details = &details
+ }
+ }
+
+ return result
+}
+
+// parseDMARCResult parses DMARC result from Authentication-Results
+// Example: dmarc=pass action=none header.from=example.com
+func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
+ result := &api.AuthResult{}
+
+ // Extract result (pass, fail, etc.)
+ re := regexp.MustCompile(`dmarc=(\w+)`)
+ if matches := re.FindStringSubmatch(part); len(matches) > 1 {
+ resultStr := strings.ToLower(matches[1])
+ result.Result = api.AuthResultResult(resultStr)
+ }
+
+ // Extract domain (header.from)
+ domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
+ if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
+ domain := matches[1]
+ result.Domain = &domain
+ }
+
+ // Extract details (action, policy, etc.)
+ var detailsParts []string
+ actionRe := regexp.MustCompile(`action=([^\s;]+)`)
+ if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
+ detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
+ }
+
+ if len(detailsParts) > 0 {
+ details := strings.Join(detailsParts, " ")
+ result.Details = &details
+ }
+
+ return result
+}
+
+// parseBIMIResult parses BIMI result from Authentication-Results
+// Example: bimi=pass header.d=example.com header.selector=default
+func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
+ result := &api.AuthResult{}
+
+ // Extract result (pass, fail, etc.)
+ re := regexp.MustCompile(`bimi=(\w+)`)
+ if matches := re.FindStringSubmatch(part); len(matches) > 1 {
+ resultStr := strings.ToLower(matches[1])
+ result.Result = api.AuthResultResult(resultStr)
+ }
+
+ // Extract domain (header.d or d)
+ domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
+ if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
+ domain := matches[1]
+ result.Domain = &domain
+ }
+
+ // Extract selector (header.selector or selector)
+ selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
+ if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
+ selector := matches[1]
+ result.Selector = &selector
+ }
+
+ // Extract details
+ if idx := strings.Index(part, "("); idx != -1 {
+ endIdx := strings.Index(part[idx:], ")")
+ if endIdx != -1 {
+ details := strings.TrimSpace(part[idx+1 : idx+endIdx])
+ result.Details = &details
+ }
+ }
+
+ return result
+}
+
+// parseARCResult parses ARC result from Authentication-Results
+// Example: arc=pass
+func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
+ result := &api.ARCResult{}
+
+ // Extract result (pass, fail, none)
+ re := regexp.MustCompile(`arc=(\w+)`)
+ if matches := re.FindStringSubmatch(part); len(matches) > 1 {
+ resultStr := strings.ToLower(matches[1])
+ result.Result = api.ARCResultResult(resultStr)
+ }
+
+ // Extract details
+ if idx := strings.Index(part, "("); idx != -1 {
+ endIdx := strings.Index(part[idx:], ")")
+ if endIdx != -1 {
+ details := strings.TrimSpace(part[idx+1 : idx+endIdx])
+ result.Details = &details
+ }
+ }
+
+ return result
+}
+
+// parseARCHeaders parses ARC headers from email message
+// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
+func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
+ // Get all ARC-related headers
+ arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
+ arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
+ arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
+
+ // If no ARC headers present, return nil
+ if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
+ return nil
+ }
+
+ result := &api.ARCResult{
+ Result: api.ARCResultResultNone,
+ }
+
+ // Count the ARC chain length (number of sets)
+ chainLength := len(arcSeal)
+ result.ChainLength = &chainLength
+
+ // Validate the ARC chain
+ chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
+ result.ChainValid = &chainValid
+
+ // Determine overall result
+ if chainLength == 0 {
+ result.Result = api.ARCResultResultNone
+ details := "No ARC chain present"
+ result.Details = &details
+ } else if !chainValid {
+ result.Result = api.ARCResultResultFail
+ details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
+ result.Details = &details
+ } else {
+ result.Result = api.ARCResultResultPass
+ details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
+ result.Details = &details
+ }
+
+ return result
+}
+
+// enhanceARCResult enhances an existing ARC result with chain information
+func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
+ if arcResult == nil {
+ return
+ }
+
+ // Get ARC headers
+ arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
+ arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
+ arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
+
+ // Set chain length if not already set
+ if arcResult.ChainLength == nil {
+ chainLength := len(arcSeal)
+ arcResult.ChainLength = &chainLength
+ }
+
+ // Validate chain if not already validated
+ if arcResult.ChainValid == nil {
+ chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
+ arcResult.ChainValid = &chainValid
+ }
+}
+
+// validateARCChain validates the ARC chain for completeness
+// Each instance should have all three headers with matching instance numbers
+func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
+ // All three header types should have the same count
+ if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
+ return false
+ }
+
+ if len(arcSeal) == 0 {
+ return true // No ARC chain is technically valid
+ }
+
+ // Extract instance numbers from each header type
+ sealInstances := a.extractARCInstances(arcSeal)
+ sigInstances := a.extractARCInstances(arcMessageSig)
+ authInstances := a.extractARCInstances(arcAuthResults)
+
+ // Check that all instance numbers match and are sequential starting from 1
+ if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
+ return false
+ }
+
+ // Verify instances are sequential from 1 to N
+ for i := 1; i <= len(sealInstances); i++ {
+ if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// extractARCInstances extracts instance numbers from ARC headers
+func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
+ var instances []int
+ re := regexp.MustCompile(`i=(\d+)`)
+
+ for _, header := range headers {
+ if matches := re.FindStringSubmatch(header); len(matches) > 1 {
+ var instance int
+ fmt.Sscanf(matches[1], "%d", &instance)
+ instances = append(instances, instance)
+ }
+ }
+
+ return instances
+}
+
+// contains checks if a slice contains an integer
+func contains(slice []int, val int) bool {
+ for _, item := range slice {
+ if item == val {
+ return true
+ }
+ }
+ return false
+}
+
+// pluralize returns "y" or "ies" based on count
+func pluralize(count int) string {
+ if count == 1 {
+ return "y"
+ }
+ return "ies"
+}
+
+// parseLegacySPF attempts to parse SPF from Received-SPF header
+func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
+ receivedSPF := email.Header.Get("Received-SPF")
+ if receivedSPF == "" {
+ return nil
+ }
+
+ result := &api.AuthResult{}
+
+ // Extract result (first word)
+ parts := strings.Fields(receivedSPF)
+ if len(parts) > 0 {
+ resultStr := strings.ToLower(parts[0])
+ result.Result = api.AuthResultResult(resultStr)
+ }
+
+ // Try to extract domain
+ domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
+ if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
+ email := matches[1]
+ if idx := strings.Index(email, "@"); idx != -1 {
+ domain := email[idx+1:]
+ result.Domain = &domain
+ }
+ }
+
+ return result
+}
+
+// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
+func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
+ var results []api.AuthResult
+
+ // Get all DKIM-Signature headers
+ dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
+ for _, dkimHeader := range dkimHeaders {
+ result := api.AuthResult{
+ Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
+ }
+
+ // Extract domain (d=)
+ domainRe := regexp.MustCompile(`d=([^\s;]+)`)
+ if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
+ domain := matches[1]
+ result.Domain = &domain
+ }
+
+ // Extract selector (s=)
+ selectorRe := regexp.MustCompile(`s=([^\s;]+)`)
+ if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
+ selector := matches[1]
+ result.Selector = &selector
+ }
+
+ details := "DKIM signature present (verification status unknown)"
+ result.Details = &details
+
+ results = append(results, result)
+ }
+
+ return results
+}
+
+// textprotoCanonical converts a header name to canonical form
+func textprotoCanonical(s string) string {
+ // Simple implementation - capitalize each word
+ words := strings.Split(s, "-")
+ for i, word := range words {
+ if len(word) > 0 {
+ words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
+ }
+ }
+ return strings.Join(words, "-")
}
diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go
deleted file mode 100644
index e7333ce..0000000
--- a/pkg/analyzer/authentication_arc.go
+++ /dev/null
@@ -1,184 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// For AGPL licensing:
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package analyzer
-
-import (
- "fmt"
- "regexp"
- "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
deleted file mode 100644
index ac51d0b..0000000
--- a/pkg/analyzer/authentication_arc_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 9654ac7..0000000
--- a/pkg/analyzer/authentication_bimi.go
+++ /dev/null
@@ -1,76 +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 (
- "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
deleted file mode 100644
index 440f356..0000000
--- a/pkg/analyzer/authentication_bimi_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
new file mode 100644
index 0000000..01298a0
--- /dev/null
+++ b/pkg/analyzer/authentication_checks.go
@@ -0,0 +1,304 @@
+// This file is part of the happyDeliver (R) project.
+// Copyright (c) 2025 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// 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
deleted file mode 100644
index 4165d8b..0000000
--- a/pkg/analyzer/authentication_dkim.go
+++ /dev/null
@@ -1,87 +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 (
- "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
deleted file mode 100644
index 0576854..0000000
--- a/pkg/analyzer/authentication_dkim_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index c89093d..0000000
--- a/pkg/analyzer/authentication_dmarc.go
+++ /dev/null
@@ -1,69 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 69779a7..0000000
--- a/pkg/analyzer/authentication_dmarc_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 3ed045c..0000000
--- a/pkg/analyzer/authentication_iprev.go
+++ /dev/null
@@ -1,74 +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 (
- "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
deleted file mode 100644
index 55f85d5..0000000
--- a/pkg/analyzer/authentication_iprev_test.go
+++ /dev/null
@@ -1,226 +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 (
- "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
deleted file mode 100644
index 1488c98..0000000
--- a/pkg/analyzer/authentication_spf.go
+++ /dev/null
@@ -1,116 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 210505a..0000000
--- a/pkg/analyzer/authentication_spf_test.go
+++ /dev/null
@@ -1,213 +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 (
- "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 0b17bf0..17ac24e 100644
--- a/pkg/analyzer/authentication_test.go
+++ b/pkg/analyzer/authentication_test.go
@@ -22,90 +22,552 @@
package analyzer
import (
+ "strings"
"testing"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
)
+func TestParseSPFResult(t *testing.T) {
+ tests := []struct {
+ name string
+ part string
+ expectedResult api.AuthResultResult
+ expectedDomain string
+ }{
+ {
+ name: "SPF pass with domain",
+ part: "spf=pass smtp.mailfrom=sender@example.com",
+ expectedResult: api.AuthResultResultPass,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF fail",
+ part: "spf=fail smtp.mailfrom=sender@example.com",
+ expectedResult: api.AuthResultResultFail,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF neutral",
+ part: "spf=neutral smtp.mailfrom=sender@example.com",
+ expectedResult: api.AuthResultResultNeutral,
+ expectedDomain: "example.com",
+ },
+ {
+ name: "SPF softfail",
+ part: "spf=softfail smtp.mailfrom=sender@example.com",
+ expectedResult: api.AuthResultResultSoftfail,
+ expectedDomain: "example.com",
+ },
+ }
+
+ analyzer := NewAuthenticationAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.parseSPFResult(tt.part)
+
+ if result.Result != tt.expectedResult {
+ t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
+ }
+ if result.Domain == nil || *result.Domain != tt.expectedDomain {
+ var gotDomain string
+ if result.Domain != nil {
+ gotDomain = *result.Domain
+ }
+ t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
+ }
+ })
+ }
+}
+
+func 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 *model.AuthenticationResults
- expectedScore int
+ results *api.AuthenticationResults
+ expectedScore float32
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
- results: &model.AuthenticationResults{
- Spf: &model.AuthResult{
- Result: model.AuthResultResultPass,
+ results: &api.AuthenticationResults{
+ Spf: &api.AuthResult{
+ Result: api.AuthResultResultPass,
},
- Dkim: &[]model.AuthResult{
- {Result: model.AuthResultResultPass},
+ Dkim: &[]api.AuthResult{
+ {Result: api.AuthResultResultPass},
},
- Dmarc: &model.AuthResult{
- Result: model.AuthResultResultPass,
+ Dmarc: &api.AuthResult{
+ Result: api.AuthResultResultPass,
},
},
- expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30
+ expectedScore: 3.0,
},
{
name: "SPF and DKIM only",
- results: &model.AuthenticationResults{
- Spf: &model.AuthResult{
- Result: model.AuthResultResultPass,
+ results: &api.AuthenticationResults{
+ Spf: &api.AuthResult{
+ Result: api.AuthResultResultPass,
},
- Dkim: &[]model.AuthResult{
- {Result: model.AuthResultResultPass},
+ Dkim: &[]api.AuthResult{
+ {Result: api.AuthResultResultPass},
},
},
- expectedScore: 60, // SPF=30 + DKIM=30
+ expectedScore: 2.0,
},
{
name: "SPF fail, DKIM pass",
- results: &model.AuthenticationResults{
- Spf: &model.AuthResult{
- Result: model.AuthResultResultFail,
+ results: &api.AuthenticationResults{
+ Spf: &api.AuthResult{
+ Result: api.AuthResultResultFail,
},
- Dkim: &[]model.AuthResult{
- {Result: model.AuthResultResultPass},
+ Dkim: &[]api.AuthResult{
+ {Result: api.AuthResultResultPass},
},
},
- expectedScore: 30, // SPF=0 + DKIM=30
+ expectedScore: 1.0,
},
{
name: "SPF softfail",
- results: &model.AuthenticationResults{
- Spf: &model.AuthResult{
- Result: model.AuthResultResultSoftfail,
+ results: &api.AuthenticationResults{
+ Spf: &api.AuthResult{
+ Result: api.AuthResultResultSoftfail,
},
},
- expectedScore: 5, // 30 * 17 / 100 = 5
+ expectedScore: 0.5,
},
{
name: "No authentication",
- results: &model.AuthenticationResults{},
- expectedScore: 0,
+ results: &api.AuthenticationResults{},
+ expectedScore: 0.0,
},
{
- name: "BIMI adds to score",
- results: &model.AuthenticationResults{
- Spf: &model.AuthResult{
- Result: model.AuthResultResultPass,
+ name: "BIMI doesn't affect score",
+ results: &api.AuthenticationResults{
+ Spf: &api.AuthResult{
+ Result: api.AuthResultResultPass,
},
- Bimi: &model.AuthResult{
- Result: model.AuthResultResultPass,
+ Bimi: &api.AuthResult{
+ Result: api.AuthResultResultPass,
},
},
- expectedScore: 40, // SPF (30) + BIMI (10)
+ expectedScore: 1.0, // Only SPF counted, not BIMI
},
}
- scorer := NewAuthenticationAnalyzer("")
+ scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- score, _ := scorer.CalculateAuthenticationScore(tt.results)
+ score := scorer.GetAuthenticationScore(tt.results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
@@ -114,326 +576,271 @@ func TestGetAuthenticationScore(t *testing.T) {
}
}
-func TestParseAuthenticationResultsHeader(t *testing.T) {
+func TestGenerateAuthenticationChecks(t *testing.T) {
tests := []struct {
- 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 string
+ results *api.AuthenticationResults
+ expectedChecks int
}{
{
- 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: "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: "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: "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: "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: "No authentication results",
+ results: &api.AuthenticationResults{},
+ expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
},
{
- 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,
+ 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
},
}
- analyzer := NewAuthenticationAnalyzer("")
+ analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- results := &model.AuthenticationResults{}
- analyzer.parseAuthenticationResultsHeader(tt.header, results)
+ checks := analyzer.GenerateAuthenticationChecks(tt.results)
- // Check SPF
- if tt.expectedSPFResult != nil {
- if results.Spf == nil {
- t.Errorf("Expected SPF result, got nil")
- } else {
- if results.Spf.Result != *tt.expectedSPFResult {
- t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult)
- }
- if tt.expectedSPFDomain != nil {
- if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain {
- var gotDomain string
- if results.Spf.Domain != nil {
- gotDomain = *results.Spf.Domain
- }
- t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain)
- }
- }
- }
- } else {
- if results.Spf != nil {
- t.Errorf("Expected no SPF result, got %+v", results.Spf)
- }
+ if len(checks) != tt.expectedChecks {
+ t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
}
- // 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)
+ // 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)
}
}
})
}
}
-func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
- // This test verifies that only the first occurrence of each auth method is parsed
- analyzer := NewAuthenticationAnalyzer("")
+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,
+ },
+ }
- 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)
+ analyzer := NewAuthenticationAnalyzer()
- 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")
- }
- })
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.parseARCResult(tt.part)
- 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)
- }
- })
+ 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)
+ }
+ })
+ }
}
diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go
deleted file mode 100644
index 45c2e2e..0000000
--- a/pkg/analyzer/authentication_x_aligned_from.go
+++ /dev/null
@@ -1,66 +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 (
- "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
deleted file mode 100644
index ee90c0d..0000000
--- a/pkg/analyzer/authentication_x_aligned_from_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index b33279e..0000000
--- a/pkg/analyzer/authentication_x_google_dkim.go
+++ /dev/null
@@ -1,74 +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 (
- "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
deleted file mode 100644
index 4013340..0000000
--- a/pkg/analyzer/authentication_x_google_dkim_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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 06f8ddf..ac46259 100644
--- a/pkg/analyzer/content.go
+++ b/pkg/analyzer/content.go
@@ -27,22 +27,18 @@ import (
"net/http"
"net/url"
"regexp"
- "slices"
"strings"
"time"
"unicode"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
- Timeout time.Duration
- httpClient *http.Client
- listUnsubscribeURLs []string // URLs from List-Unsubscribe header
- hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
+ Timeout time.Duration
+ httpClient *http.Client
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
@@ -67,7 +63,6 @@ func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
// ContentResults represents content analysis results
type ContentResults struct {
- IsMultipart bool
HTMLValid bool
HTMLErrors []string
Links []LinkCheck
@@ -80,12 +75,6 @@ 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
@@ -112,15 +101,6 @@ 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()
@@ -137,57 +117,16 @@ 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
@@ -231,18 +170,6 @@ 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
@@ -268,59 +195,6 @@ 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: "Text More",
+ expectedText: "TextMore",
},
{
name: "With style tag",
html: "Text
More
",
- expectedText: "Text More",
+ expectedText: "TextMore",
},
{
name: "Empty HTML",
@@ -144,74 +145,6 @@ func TestIsUnsubscribeLink(t *testing.T) {
linkText: "Read more",
expected: false,
},
- // Multilingual keyword detection - URL path
- {
- name: "German abmelden in URL",
- href: "https://example.com/abmelden?id=42",
- linkText: "Click here",
- expected: true,
- },
- {
- name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
- href: "https://example.com/se-desabonner?id=42",
- linkText: "Click here",
- expected: false,
- },
- // Multilingual keyword detection - link text
- {
- name: "German Abmelden in link text",
- href: "https://example.com/manage?id=42&lang=de",
- linkText: "Abmelden",
- expected: true,
- },
- {
- name: "French Se désabonner in link text",
- href: "https://example.com/manage?id=42&lang=fr",
- linkText: "Se désabonner",
- expected: true,
- },
- {
- name: "Russian Отписаться in link text",
- href: "https://example.com/manage?id=42&lang=ru",
- linkText: "Отписаться",
- expected: true,
- },
- {
- name: "Chinese 退订 in link text",
- href: "https://example.com/manage?id=42&lang=zh",
- linkText: "退订",
- expected: true,
- },
- {
- name: "Japanese 登録を取り消す in link text",
- href: "https://example.com/manage?id=42&lang=ja",
- linkText: "登録を取り消す",
- expected: true,
- },
- {
- name: "Korean 구독 해지 in link text",
- href: "https://example.com/manage?id=42&lang=ko",
- linkText: "구독 해지",
- expected: true,
- },
- {
- name: "Dutch Uitschrijven in link text",
- href: "https://example.com/manage?id=42&lang=nl",
- linkText: "Uitschrijven",
- expected: true,
- },
- {
- name: "Polish Odsubskrybuj in link text",
- href: "https://example.com/manage?id=42&lang=pl",
- linkText: "Odsubskrybuj",
- expected: true,
- },
- {
- name: "Turkish Üyeliği sonlandır in link text",
- href: "https://example.com/manage?id=42&lang=tr",
- linkText: "Üyeliği sonlandır",
- expected: true,
- },
}
analyzer := NewContentAnalyzer(5 * time.Second)
@@ -281,16 +214,6 @@ func TestIsSuspiciousURL(t *testing.T) {
url: "https://mail.example.com/page",
expected: false,
},
- {
- name: "Mailto with @ symbol",
- url: "mailto:support@example.com",
- expected: false,
- },
- {
- name: "Mailto with multiple @ symbols",
- url: "mailto:user@subdomain@example.com",
- expected: false,
- },
}
analyzer := NewContentAnalyzer(5 * time.Second)
@@ -685,6 +608,453 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
}
}
+func TestGenerateHTMLValidityCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid HTML",
+ results: &ContentResults{
+ HTMLValid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 0.2,
+ },
+ {
+ name: "Invalid HTML",
+ results: &ContentResults{
+ HTMLValid: false,
+ HTMLErrors: []string{"Parse error"},
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateHTMLValidityCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Content {
+ t.Errorf("Category = %v, want %v", check.Category, api.Content)
+ }
+ })
+ }
+}
+
+func TestGenerateLinkChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "All links valid",
+ results: &ContentResults{
+ Links: []LinkCheck{
+ {URL: "https://example.com", Valid: true, Status: 200},
+ {URL: "https://example.org", Valid: true, Status: 200},
+ },
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 0.4,
+ },
+ {
+ name: "Broken links",
+ results: &ContentResults{
+ Links: []LinkCheck{
+ {URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
+ },
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ {
+ name: "Links with warnings",
+ results: &ContentResults{
+ Links: []LinkCheck{
+ {URL: "https://example.com", Valid: true, Warning: "Could not verify"},
+ },
+ },
+ expectedStatus: api.CheckStatusWarn,
+ expectedScore: 0.3,
+ },
+ {
+ name: "No links",
+ results: &ContentResults{},
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ checks := analyzer.generateLinkChecks(tt.results)
+
+ if tt.name == "No links" {
+ if len(checks) != 0 {
+ t.Errorf("Expected no checks, got %d", len(checks))
+ }
+ return
+ }
+
+ if len(checks) == 0 {
+ t.Fatal("Expected at least one check")
+ }
+
+ check := checks[0]
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ })
+ }
+}
+
+func TestGenerateImageChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ }{
+ {
+ name: "All images have alt",
+ results: &ContentResults{
+ Images: []ImageCheck{
+ {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
+ {Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
+ },
+ },
+ expectedStatus: api.CheckStatusPass,
+ },
+ {
+ name: "No images have alt",
+ results: &ContentResults{
+ Images: []ImageCheck{
+ {Src: "img1.jpg", HasAlt: false},
+ {Src: "img2.jpg", HasAlt: false},
+ },
+ },
+ expectedStatus: api.CheckStatusFail,
+ },
+ {
+ name: "Some images have alt",
+ results: &ContentResults{
+ Images: []ImageCheck{
+ {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
+ {Src: "img2.jpg", HasAlt: false},
+ },
+ },
+ expectedStatus: api.CheckStatusWarn,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ checks := analyzer.generateImageChecks(tt.results)
+
+ if len(checks) == 0 {
+ t.Fatal("Expected at least one check")
+ }
+
+ check := checks[0]
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Category != api.Content {
+ t.Errorf("Category = %v, want %v", check.Category, api.Content)
+ }
+ })
+ }
+}
+
+func TestGenerateUnsubscribeCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ }{
+ {
+ name: "Has unsubscribe link",
+ results: &ContentResults{
+ HasUnsubscribe: true,
+ UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
+ },
+ expectedStatus: api.CheckStatusPass,
+ },
+ {
+ name: "No unsubscribe link",
+ results: &ContentResults{},
+ expectedStatus: api.CheckStatusWarn,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateUnsubscribeCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Category != api.Content {
+ t.Errorf("Category = %v, want %v", check.Category, api.Content)
+ }
+ })
+ }
+}
+
+func TestGenerateTextConsistencyCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ }{
+ {
+ name: "High consistency",
+ results: &ContentResults{
+ TextPlainRatio: 0.8,
+ },
+ expectedStatus: api.CheckStatusPass,
+ },
+ {
+ name: "Low consistency",
+ results: &ContentResults{
+ TextPlainRatio: 0.1,
+ },
+ expectedStatus: api.CheckStatusWarn,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateTextConsistencyCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ })
+ }
+}
+
+func TestGenerateImageRatioCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ expectedStatus api.CheckStatus
+ }{
+ {
+ name: "Reasonable ratio",
+ results: &ContentResults{
+ ImageTextRatio: 3.0,
+ Images: []ImageCheck{{}, {}, {}},
+ },
+ expectedStatus: api.CheckStatusPass,
+ },
+ {
+ name: "High ratio",
+ results: &ContentResults{
+ ImageTextRatio: 7.0,
+ Images: make([]ImageCheck, 7),
+ },
+ expectedStatus: api.CheckStatusWarn,
+ },
+ {
+ name: "Excessive ratio",
+ results: &ContentResults{
+ ImageTextRatio: 15.0,
+ Images: make([]ImageCheck, 15),
+ },
+ expectedStatus: api.CheckStatusFail,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateImageRatioCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ })
+ }
+}
+
+func TestGenerateSuspiciousURLCheck(t *testing.T) {
+ results := &ContentResults{
+ SuspiciousURLs: []string{
+ "https://bit.ly/abc123",
+ "https://192.168.1.1/page",
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+ check := analyzer.generateSuspiciousURLCheck(results)
+
+ if check.Status != api.CheckStatusWarn {
+ t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
+ }
+ if check.Category != api.Content {
+ t.Errorf("Category = %v, want %v", check.Category, api.Content)
+ }
+ if !strings.Contains(check.Message, "2") {
+ t.Error("Message should mention the count of suspicious URLs")
+ }
+}
+
+func TestGetContentScore(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ minScore float32
+ maxScore float32
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ minScore: 0.0,
+ maxScore: 0.0,
+ },
+ {
+ name: "Perfect content",
+ results: &ContentResults{
+ HTMLValid: true,
+ Links: []LinkCheck{{Valid: true, Status: 200}},
+ Images: []ImageCheck{{HasAlt: true}},
+ HasUnsubscribe: true,
+ TextPlainRatio: 0.8,
+ ImageTextRatio: 3.0,
+ },
+ minScore: 1.8,
+ maxScore: 2.0,
+ },
+ {
+ name: "Poor content",
+ results: &ContentResults{
+ HTMLValid: false,
+ Links: []LinkCheck{{Valid: true, Status: 404}},
+ Images: []ImageCheck{{HasAlt: false}},
+ HasUnsubscribe: false,
+ TextPlainRatio: 0.1,
+ ImageTextRatio: 15.0,
+ SuspiciousURLs: []string{"url1", "url2"},
+ },
+ minScore: 0.0,
+ maxScore: 0.5,
+ },
+ {
+ name: "Average content",
+ results: &ContentResults{
+ HTMLValid: true,
+ Links: []LinkCheck{{Valid: true, Status: 200}},
+ Images: []ImageCheck{{HasAlt: true}},
+ HasUnsubscribe: false,
+ TextPlainRatio: 0.5,
+ ImageTextRatio: 4.0,
+ },
+ minScore: 1.0,
+ maxScore: 1.8,
+ },
+ }
+
+ analyzer := NewContentAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ score := analyzer.GetContentScore(tt.results)
+
+ if score < tt.minScore || score > tt.maxScore {
+ t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
+ }
+
+ // Ensure score is capped at 2.0
+ if score > 2.0 {
+ t.Errorf("Score %v exceeds maximum of 2.0", score)
+ }
+
+ // Ensure score is not negative
+ if score < 0.0 {
+ t.Errorf("Score %v is negative", score)
+ }
+ })
+ }
+}
+
+func TestGenerateContentChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ results *ContentResults
+ minChecks int
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ minChecks: 0,
+ },
+ {
+ name: "Complete results",
+ results: &ContentResults{
+ HTMLValid: true,
+ Links: []LinkCheck{{Valid: true}},
+ Images: []ImageCheck{{HasAlt: true}},
+ HasUnsubscribe: true,
+ TextContent: "Plain text",
+ HTMLContent: "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) {
@@ -706,276 +1076,3 @@ func findFirstLink(n *html.Node) *html.Node {
func parseURL(urlStr string) (*url.URL, error) {
return url.Parse(urlStr)
}
-
-func TestHasDomainMisalignment(t *testing.T) {
- tests := []struct {
- name string
- href string
- linkText string
- expected bool
- reason string
- }{
- // Phishing cases - should return true
- {
- name: "Obvious phishing - different domains",
- href: "https://evil.com/page",
- linkText: "Click here to verify your paypal.com account",
- expected: true,
- reason: "Link text shows 'paypal.com' but URL points to 'evil.com'",
- },
- {
- name: "Domain in link text differs from URL",
- href: "http://attacker.net",
- linkText: "Visit google.com for more info",
- expected: true,
- reason: "Link text shows 'google.com' but URL points to 'attacker.net'",
- },
- {
- name: "URL shown in text differs from actual URL",
- href: "https://phishing-site.xyz/login",
- linkText: "https://www.bank.example.com/secure",
- expected: true,
- reason: "Full URL in text doesn't match actual destination",
- },
- {
- name: "Similar but different domain",
- href: "https://paypa1.com/login",
- linkText: "Login to your paypal.com account",
- expected: true,
- reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'",
- },
- {
- name: "Subdomain spoofing",
- href: "https://paypal.com.evil.com/login",
- linkText: "Verify your paypal.com account",
- expected: true,
- reason: "Domain is 'evil.com', not 'paypal.com'",
- },
- {
- name: "Multiple domains in text, none match",
- href: "https://badsite.com",
- linkText: "Transfer from bank.com to paypal.com",
- expected: true,
- reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'",
- },
-
- // Legitimate cases - should return false
- {
- name: "Exact domain match",
- href: "https://example.com/page",
- linkText: "Visit example.com for more information",
- expected: false,
- reason: "Domains match exactly",
- },
- {
- name: "Legitimate subdomain",
- href: "https://mail.google.com/inbox",
- linkText: "Check your google.com email",
- expected: false,
- reason: "Subdomain of the mentioned domain",
- },
- {
- name: "www prefix variation",
- href: "https://www.example.com/page",
- linkText: "Visit example.com",
- expected: false,
- reason: "www prefix is acceptable variation",
- },
- {
- name: "Generic link text - click here",
- href: "https://anywhere.com",
- linkText: "click here",
- expected: false,
- reason: "Generic text doesn't contain a domain",
- },
- {
- name: "Generic link text - read more",
- href: "https://example.com/article",
- linkText: "Read more",
- expected: false,
- reason: "Generic text doesn't contain a domain",
- },
- {
- name: "Generic link text - learn more",
- href: "https://example.com/info",
- linkText: "Learn More",
- expected: false,
- reason: "Generic text doesn't contain a domain (case insensitive)",
- },
- {
- name: "No domain in link text",
- href: "https://example.com/page",
- linkText: "Click to continue",
- expected: false,
- reason: "Link text has no domain reference",
- },
- {
- name: "Short link text",
- href: "https://example.com",
- linkText: "Go",
- expected: false,
- reason: "Text too short to contain meaningful domain",
- },
- {
- name: "Empty link text",
- href: "https://example.com",
- linkText: "",
- expected: false,
- reason: "Empty text cannot contain domain",
- },
- {
- name: "Mailto link - matching domain",
- href: "mailto:support@example.com",
- linkText: "Email support@example.com",
- expected: false,
- reason: "Mailto email matches text email",
- },
- {
- name: "Mailto link - domain mismatch (phishing)",
- href: "mailto:attacker@evil.com",
- linkText: "Contact support@paypal.com for help",
- expected: true,
- reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'",
- },
- {
- name: "Mailto link - generic text",
- href: "mailto:info@example.com",
- linkText: "Contact us",
- expected: false,
- reason: "Generic text without domain reference",
- },
- {
- name: "Mailto link - same domain different user",
- href: "mailto:sales@example.com",
- linkText: "Email support@example.com",
- expected: false,
- reason: "Both emails share the same domain",
- },
- {
- name: "Mailto link - text shows only domain",
- href: "mailto:info@example.com",
- linkText: "Write to example.com",
- expected: false,
- reason: "Text domain matches mailto domain",
- },
- {
- name: "Mailto link - domain in text doesn't match",
- href: "mailto:scam@phishing.net",
- linkText: "Reply to customer-service@amazon.com",
- expected: true,
- reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text",
- },
- {
- name: "Tel link",
- href: "tel:+1234567890",
- linkText: "Call example.com support",
- expected: false,
- reason: "Non-HTTP(S) links are excluded",
- },
- {
- name: "Same base domain with different subdomains",
- href: "https://www.example.com/page",
- linkText: "Visit blog.example.com",
- expected: false,
- reason: "Both share same base domain 'example.com'",
- },
- {
- name: "URL with path matches domain in text",
- href: "https://example.com/section/page",
- linkText: "Go to example.com",
- expected: false,
- reason: "Domain matches, path doesn't matter",
- },
- {
- name: "Generic text - subscribe",
- href: "https://newsletter.example.com/signup",
- linkText: "Subscribe",
- expected: false,
- reason: "Generic call-to-action text",
- },
- {
- name: "Generic text - unsubscribe",
- href: "https://example.com/unsubscribe?id=123",
- linkText: "Unsubscribe",
- expected: false,
- reason: "Generic unsubscribe text",
- },
- {
- name: "Generic text - download",
- href: "https://files.example.com/document.pdf",
- linkText: "Download",
- expected: false,
- reason: "Generic action text",
- },
- {
- name: "Descriptive text without domain",
- href: "https://shop.example.com/products",
- linkText: "View our latest products",
- expected: false,
- reason: "No domain mentioned in text",
- },
-
- // Edge cases
- {
- name: "Domain-like text but not valid domain",
- href: "https://example.com",
- linkText: "Save up to 50.00 dollars",
- expected: false,
- reason: "50.00 looks like domain but isn't",
- },
- {
- name: "Text with http prefix matching domain",
- href: "https://example.com/page",
- linkText: "Visit http://example.com",
- expected: false,
- reason: "Domains match despite different protocols in display",
- },
- {
- name: "Port in URL should not affect matching",
- href: "https://example.com:8080/page",
- linkText: "Go to example.com",
- expected: false,
- reason: "Port number doesn't affect domain matching",
- },
- {
- name: "Whitespace in link text",
- href: "https://example.com",
- linkText: " example.com ",
- expected: false,
- reason: "Whitespace should be trimmed",
- },
- {
- name: "Multiple spaces in generic text",
- href: "https://example.com",
- linkText: "click here",
- expected: false,
- reason: "Generic text with extra spaces",
- },
- {
- name: "Anchor fragment in URL",
- href: "https://example.com/page#section",
- linkText: "example.com section",
- expected: false,
- reason: "Fragment doesn't affect domain matching",
- },
- {
- name: "Query parameters in URL",
- href: "https://example.com/page?utm_source=email",
- linkText: "Visit example.com",
- expected: false,
- reason: "Query params don't affect domain matching",
- },
- }
-
- analyzer := NewContentAnalyzer(5 * time.Second)
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := analyzer.hasDomainMisalignment(tt.href, tt.linkText)
- if result != tt.expected {
- t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s",
- tt.href, tt.linkText, result, tt.expected, tt.reason)
- }
- })
- }
-}
diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go
index 6bc7c39..9a6d26f 100644
--- a/pkg/analyzer/dns.go
+++ b/pkg/analyzer/dns.go
@@ -22,215 +22,698 @@
package analyzer
import (
+ "context"
+ "fmt"
+ "net"
+ "regexp"
+ "strings"
"time"
- "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
- resolver DNSResolver
+ resolver *net.Resolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
- return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver())
-}
-
-// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver.
-// If resolver is nil, a StandardDNSResolver will be used.
-func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
- if resolver == nil {
- resolver = NewStandardDNSResolver()
- }
return &DNSAnalyzer{
- Timeout: timeout,
- resolver: resolver,
+ Timeout: timeout,
+ resolver: &net.Resolver{
+ PreferGo: true,
+ },
}
}
+// DNSResults represents DNS validation results for an email
+type DNSResults struct {
+ Domain string
+ MXRecords []MXRecord
+ SPFRecord *SPFRecord
+ DKIMRecords []DKIMRecord
+ DMARCRecord *DMARCRecord
+ BIMIRecord *BIMIRecord
+ Errors []string
+}
+
+// MXRecord represents an MX record
+type MXRecord struct {
+ Host string
+ Priority uint16
+ Valid bool
+ Error string
+}
+
+// SPFRecord represents an SPF record
+type SPFRecord struct {
+ Record string
+ Valid bool
+ Error string
+}
+
+// DKIMRecord represents a DKIM record
+type DKIMRecord struct {
+ Selector string
+ Domain string
+ Record string
+ Valid bool
+ Error string
+}
+
+// DMARCRecord represents a DMARC record
+type DMARCRecord struct {
+ Record string
+ Policy string // none, quarantine, reject
+ Valid bool
+ Error string
+}
+
+// BIMIRecord represents a BIMI record
+type BIMIRecord struct {
+ Selector string
+ Domain string
+ Record string
+ LogoURL string // URL to the brand logo (SVG)
+ VMCURL string // URL to Verified Mark Certificate (optional)
+ Valid bool
+ Error string
+}
+
// AnalyzeDNS performs DNS validation for the email's domain
-func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults {
+func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
// Extract domain from From address
- if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
- return &model.DNSResults{
- Errors: &[]string{"Unable to extract domain from email"},
- }
- }
- fromDomain := *headersResults.DomainAlignment.FromDomain
-
- results := &model.DNSResults{
- FromDomain: fromDomain,
- RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
- }
-
- // Determine which domain to check SPF for (Return-Path domain)
- // SPF validates the envelope sender (Return-Path), not the From header
- spfDomain := fromDomain
- if results.RpDomain != nil {
- spfDomain = *results.RpDomain
- }
-
- // Store sender IP for later use in scoring
- var senderIP string
- if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 {
- firstHop := (*headersResults.ReceivedChain)[0]
- if firstHop.Ip != nil && *firstHop.Ip != "" {
- senderIP = *firstHop.Ip
- ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP)
- if len(ptrRecords) > 0 {
- results.PtrRecords = &ptrRecords
- }
- if len(forwardRecords) > 0 {
- results.PtrForwardRecords = &forwardRecords
- }
+ domain := d.extractDomain(email)
+ if domain == "" {
+ return &DNSResults{
+ Errors: []string{"Unable to extract domain from email"},
}
}
- // Check MX records for From domain (where replies would go)
- results.FromMxRecords = d.checkMXRecords(fromDomain)
-
- // Check MX records for Return-Path domain (where bounces would go)
- // Only check if Return-Path domain is different from From domain
- if results.RpDomain != nil && *results.RpDomain != fromDomain {
- results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
- }
-
- // 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,
+ results := &DNSResults{
+ Domain: domain,
}
// Check MX records
- results.FromMxRecords = d.checkMXRecords(domain)
+ results.MXRecords = d.checkMXRecords(domain)
- // Check SPF records
- results.SpfRecords = d.checkSPFRecords(domain)
+ // Check SPF record
+ results.SPFRecord = d.checkSPFRecord(domain)
+
+ // Check DKIM records (from authentication results)
+ if authResults != nil && authResults.Dkim != nil {
+ for _, dkim := range *authResults.Dkim {
+ if dkim.Domain != nil && dkim.Selector != nil {
+ dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
+ if dkimRecord != nil {
+ results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
+ }
+ }
+ }
+ }
// Check DMARC record
- results.DmarcRecord = d.checkDMARCRecord(domain)
+ results.DMARCRecord = d.checkDMARCRecord(domain)
- // Check BIMI record with default selector
- results.BimiRecord = d.checkBIMIRecord(domain, "default")
+ // Check BIMI record (using default selector)
+ results.BIMIRecord = d.checkBIMIRecord(domain, "default")
return results
}
-// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
-// Returns a score from 0-100 where higher is better
-// This version excludes PTR and DKIM checks since they require email context
-func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
- if results == nil {
- return 0, ""
+// extractDomain extracts the domain from the email's From address
+func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
+ if email.From != nil && email.From.Address != "" {
+ parts := strings.Split(email.From.Address, "@")
+ if len(parts) == 2 {
+ return strings.ToLower(strings.TrimSpace(parts[1]))
+ }
}
+ return ""
+}
- score := 0
+// checkMXRecords looks up MX records for a domain
+func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
- // MX Records: 30 points (only one domain to check)
- mxScore := d.calculateMXScore(results)
- // Since calculateMXScore checks both From and RP domains,
- // and we only have From domain, we use the full score
- score += 30 * mxScore / 100
-
- // SPF Records: 30 points
- score += 30 * d.calculateSPFScore(results) / 100
-
- // DMARC Record: 40 points
- score += 40 * d.calculateDMARCScore(results) / 100
-
- // BIMI Record: only bonus
- if results.BimiRecord != nil && results.BimiRecord.Valid {
- if score >= 100 {
- return 100, "A+"
+ mxRecords, err := d.resolver.LookupMX(ctx, domain)
+ if err != nil {
+ return []MXRecord{
+ {
+ Valid: false,
+ Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
+ },
}
}
- // Ensure score doesn't exceed maximum
- if score > 100 {
- score = 100
- }
-
- // Ensure score is non-negative
- if score < 0 {
- score = 0
- }
-
- return score, ScoreToGradeKind(score)
-}
-
-// CalculateDNSScore calculates the DNS score from records results
-// Returns a score from 0-100 where higher is better
-// senderIP is the original sender IP address used for FCrDNS verification
-func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
- if results == nil {
- return 0, ""
- }
-
- score := 0
-
- // PTR and Forward DNS: 20 points
- score += 20 * d.calculatePTRScore(results, senderIP) / 100
-
- // MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
- score += 20 * d.calculateMXScore(results) / 100
-
- // SPF Records: 20 points
- score += 20 * d.calculateSPFScore(results) / 100
-
- // DKIM Records: 20 points
- score += 20 * d.calculateDKIMScore(results) / 100
-
- // DMARC Record: 20 points
- score += 20 * d.calculateDMARCScore(results) / 100
-
- // BIMI Record
- // BIMI is optional but indicates advanced email branding
- if results.BimiRecord != nil && results.BimiRecord.Valid {
- if score >= 100 {
- return 100, "A+"
+ if len(mxRecords) == 0 {
+ return []MXRecord{
+ {
+ Valid: false,
+ Error: "No MX records found",
+ },
}
}
- // Ensure score doesn't exceed maximum
- if score > 100 {
- score = 100
+ var results []MXRecord
+ for _, mx := range mxRecords {
+ results = append(results, MXRecord{
+ Host: mx.Host,
+ Priority: mx.Pref,
+ Valid: true,
+ })
}
- // Ensure score is non-negative
- if score < 0 {
- score = 0
- }
-
- return score, ScoreToGrade(score)
+ return results
+}
+
+// checkSPFRecord looks up and validates SPF record for a domain
+func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, err := d.resolver.LookupTXT(ctx, domain)
+ if err != nil {
+ return &SPFRecord{
+ Valid: false,
+ Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
+ }
+ }
+
+ // Find SPF record (starts with "v=spf1")
+ var spfRecord string
+ spfCount := 0
+ for _, txt := range txtRecords {
+ if strings.HasPrefix(txt, "v=spf1") {
+ spfRecord = txt
+ spfCount++
+ }
+ }
+
+ if spfCount == 0 {
+ return &SPFRecord{
+ Valid: false,
+ Error: "No SPF record found",
+ }
+ }
+
+ if spfCount > 1 {
+ return &SPFRecord{
+ Record: spfRecord,
+ Valid: false,
+ Error: "Multiple SPF records found (RFC violation)",
+ }
+ }
+
+ // Basic validation
+ if !d.validateSPF(spfRecord) {
+ return &SPFRecord{
+ Record: spfRecord,
+ Valid: false,
+ Error: "SPF record appears malformed",
+ }
+ }
+
+ return &SPFRecord{
+ Record: spfRecord,
+ Valid: true,
+ }
+}
+
+// validateSPF performs basic SPF record validation
+func (d *DNSAnalyzer) validateSPF(record string) bool {
+ // Must start with v=spf1
+ if !strings.HasPrefix(record, "v=spf1") {
+ return false
+ }
+
+ // Check for common syntax issues
+ // Should have a final mechanism (all, +all, -all, ~all, ?all)
+ validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
+ hasValidEnding := false
+ for _, ending := range validEndings {
+ if strings.HasSuffix(record, ending) {
+ hasValidEnding = true
+ break
+ }
+ }
+
+ return hasValidEnding
+}
+
+// checkDKIMRecord looks up and validates DKIM record for a domain and selector
+func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
+ // DKIM records are at: selector._domainkey.domain
+ dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
+
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
+ if err != nil {
+ return &DKIMRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
+ }
+ }
+
+ if len(txtRecords) == 0 {
+ return &DKIMRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: "No DKIM record found",
+ }
+ }
+
+ // Concatenate all TXT record parts (DKIM can be split)
+ dkimRecord := strings.Join(txtRecords, "")
+
+ // Basic validation - should contain "v=DKIM1" and "p=" (public key)
+ if !d.validateDKIM(dkimRecord) {
+ return &DKIMRecord{
+ Selector: selector,
+ Domain: domain,
+ Record: dkimRecord,
+ Valid: false,
+ Error: "DKIM record appears malformed",
+ }
+ }
+
+ return &DKIMRecord{
+ Selector: selector,
+ Domain: domain,
+ Record: dkimRecord,
+ Valid: true,
+ }
+}
+
+// validateDKIM performs basic DKIM record validation
+func (d *DNSAnalyzer) validateDKIM(record string) bool {
+ // Should contain p= tag (public key)
+ if !strings.Contains(record, "p=") {
+ return false
+ }
+
+ // Often contains v=DKIM1 but not required
+ // If v= is present, it should be DKIM1
+ if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
+ return false
+ }
+
+ return true
+}
+
+// checkDMARCRecord looks up and validates DMARC record for a domain
+func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
+ // DMARC records are at: _dmarc.domain
+ dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
+
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
+ if err != nil {
+ return &DMARCRecord{
+ Valid: false,
+ Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
+ }
+ }
+
+ // Find DMARC record (starts with "v=DMARC1")
+ var dmarcRecord string
+ for _, txt := range txtRecords {
+ if strings.HasPrefix(txt, "v=DMARC1") {
+ dmarcRecord = txt
+ break
+ }
+ }
+
+ if dmarcRecord == "" {
+ return &DMARCRecord{
+ Valid: false,
+ Error: "No DMARC record found",
+ }
+ }
+
+ // Extract policy
+ policy := d.extractDMARCPolicy(dmarcRecord)
+
+ // Basic validation
+ if !d.validateDMARC(dmarcRecord) {
+ return &DMARCRecord{
+ Record: dmarcRecord,
+ Policy: policy,
+ Valid: false,
+ Error: "DMARC record appears malformed",
+ }
+ }
+
+ return &DMARCRecord{
+ Record: dmarcRecord,
+ Policy: policy,
+ Valid: true,
+ }
+}
+
+// extractDMARCPolicy extracts the policy from a DMARC record
+func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
+ // Look for p=none, p=quarantine, or p=reject
+ re := regexp.MustCompile(`p=(none|quarantine|reject)`)
+ matches := re.FindStringSubmatch(record)
+ if len(matches) > 1 {
+ return matches[1]
+ }
+ return "unknown"
+}
+
+// validateDMARC performs basic DMARC record validation
+func (d *DNSAnalyzer) validateDMARC(record string) bool {
+ // Must start with v=DMARC1
+ if !strings.HasPrefix(record, "v=DMARC1") {
+ return false
+ }
+
+ // Must have a policy tag
+ if !strings.Contains(record, "p=") {
+ return false
+ }
+
+ return true
+}
+
+// checkBIMIRecord looks up and validates BIMI record for a domain and selector
+func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
+ // BIMI records are at: selector._bimi.domain
+ bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
+
+ ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
+ defer cancel()
+
+ txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
+ if err != nil {
+ return &BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
+ }
+ }
+
+ if len(txtRecords) == 0 {
+ return &BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Valid: false,
+ Error: "No BIMI record found",
+ }
+ }
+
+ // Concatenate all TXT record parts (BIMI can be split)
+ bimiRecord := strings.Join(txtRecords, "")
+
+ // Extract logo URL and VMC URL
+ logoURL := d.extractBIMITag(bimiRecord, "l")
+ vmcURL := d.extractBIMITag(bimiRecord, "a")
+
+ // Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
+ if !d.validateBIMI(bimiRecord) {
+ return &BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Record: bimiRecord,
+ LogoURL: logoURL,
+ VMCURL: vmcURL,
+ Valid: false,
+ Error: "BIMI record appears malformed",
+ }
+ }
+
+ return &BIMIRecord{
+ Selector: selector,
+ Domain: domain,
+ Record: bimiRecord,
+ LogoURL: logoURL,
+ VMCURL: vmcURL,
+ Valid: true,
+ }
+}
+
+// extractBIMITag extracts a tag value from a BIMI record
+func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
+ // Look for tag=value pattern
+ re := regexp.MustCompile(tag + `=([^;]+)`)
+ matches := re.FindStringSubmatch(record)
+ if len(matches) > 1 {
+ return strings.TrimSpace(matches[1])
+ }
+ return ""
+}
+
+// validateBIMI performs basic BIMI record validation
+func (d *DNSAnalyzer) validateBIMI(record string) bool {
+ // Must start with v=BIMI1
+ if !strings.HasPrefix(record, "v=BIMI1") {
+ return false
+ }
+
+ // Must have a logo URL tag (l=)
+ if !strings.Contains(record, "l=") {
+ return false
+ }
+
+ return true
+}
+
+// GenerateDNSChecks generates check results for DNS validation
+func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
+ var checks []api.Check
+
+ if results == nil {
+ return checks
+ }
+
+ // MX record check
+ checks = append(checks, d.generateMXCheck(results))
+
+ // SPF record check
+ if results.SPFRecord != nil {
+ checks = append(checks, d.generateSPFCheck(results.SPFRecord))
+ }
+
+ // DKIM record checks
+ for _, dkim := range results.DKIMRecords {
+ checks = append(checks, d.generateDKIMCheck(&dkim))
+ }
+
+ // DMARC record check
+ if results.DMARCRecord != nil {
+ checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
+ }
+
+ // BIMI record check (optional)
+ if results.BIMIRecord != nil {
+ checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
+ }
+
+ return checks
+}
+
+// generateMXCheck creates a check for MX records
+func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
+ check := api.Check{
+ Category: api.Dns,
+ Name: "MX Records",
+ }
+
+ if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Severity = api.PtrTo(api.CheckSeverityCritical)
+
+ if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
+ check.Message = results.MXRecords[0].Error
+ } else {
+ check.Message = "No valid MX records found"
+ }
+ check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
+ } else {
+ check.Status = api.CheckStatusPass
+ check.Score = 1.0
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
+
+ // Add details about MX records
+ var mxList []string
+ for _, mx := range results.MXRecords {
+ mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
+ }
+ details := strings.Join(mxList, ", ")
+ check.Details = &details
+ check.Advice = api.PtrTo("Your MX records are properly configured")
+ }
+
+ return check
+}
+
+// generateSPFCheck creates a check for SPF records
+func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
+ check := api.Check{
+ Category: api.Dns,
+ Name: "SPF Record",
+ }
+
+ if !spf.Valid {
+ // If no record exists at all, it's a failure
+ if spf.Record == "" {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Message = spf.Error
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
+ } else {
+ // If record exists but is invalid, it's a warning
+ check.Status = api.CheckStatusWarn
+ check.Score = 0.5
+ check.Message = "SPF record found but appears invalid"
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ check.Advice = api.PtrTo("Review and fix your SPF record syntax")
+ check.Details = &spf.Record
+ }
+ } else {
+ check.Status = api.CheckStatusPass
+ check.Score = 1.0
+ check.Message = "Valid SPF record found"
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Details = &spf.Record
+ check.Advice = api.PtrTo("Your SPF record is properly configured")
+ }
+
+ return check
+}
+
+// generateDKIMCheck creates a check for DKIM records
+func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
+ check := api.Check{
+ Category: api.Dns,
+ Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
+ }
+
+ if !dkim.Valid {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
+ details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
+ check.Details = &details
+ } else {
+ check.Status = api.CheckStatusPass
+ check.Score = 1.0
+ check.Message = "Valid DKIM record found"
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
+ check.Details = &details
+ check.Advice = api.PtrTo("Your DKIM record is properly published")
+ }
+
+ return check
+}
+
+// generateDMARCCheck creates a check for DMARC records
+func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
+ check := api.Check{
+ Category: api.Dns,
+ Name: "DMARC Record",
+ }
+
+ if !dmarc.Valid {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Message = dmarc.Error
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
+ } else {
+ check.Status = api.CheckStatusPass
+ check.Score = 1.0
+ check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Details = &dmarc.Record
+
+ // Provide advice based on policy
+ switch dmarc.Policy {
+ case "none":
+ advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
+ check.Advice = &advice
+ case "quarantine":
+ advice := "DMARC policy is set to 'quarantine'. This provides good protection"
+ check.Advice = &advice
+ case "reject":
+ advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
+ check.Advice = &advice
+ default:
+ advice := "Your DMARC record is properly configured"
+ check.Advice = &advice
+ }
+ }
+
+ return check
+}
+
+// generateBIMICheck creates a check for BIMI records
+func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
+ check := api.Check{
+ Category: api.Dns,
+ Name: "BIMI Record",
+ }
+
+ if !bimi.Valid {
+ // BIMI is optional, so missing record is just informational
+ if bimi.Record == "" {
+ check.Status = api.CheckStatusInfo
+ check.Score = 0.0
+ check.Message = "No BIMI record found (optional)"
+ check.Severity = api.PtrTo(api.CheckSeverityLow)
+ check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
+ } else {
+ // If record exists but is invalid
+ check.Status = api.CheckStatusWarn
+ check.Score = 0.0
+ check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
+ check.Severity = api.PtrTo(api.CheckSeverityLow)
+ check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
+ check.Details = &bimi.Record
+ }
+ } else {
+ check.Status = api.CheckStatusPass
+ check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
+ check.Message = "Valid BIMI record found"
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+
+ // Build details with logo and VMC URLs
+ var detailsParts []string
+ detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
+ if bimi.LogoURL != "" {
+ detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
+ }
+ if bimi.VMCURL != "" {
+ detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
+ check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
+ } else {
+ check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
+ }
+
+ details := strings.Join(detailsParts, ", ")
+ check.Details = &details
+ }
+
+ return check
}
diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go
deleted file mode 100644
index 223bfdc..0000000
--- a/pkg/analyzer/dns_bimi.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index cf7df83..0000000
--- a/pkg/analyzer/dns_bimi_test.go
+++ /dev/null
@@ -1,128 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 115e347..0000000
--- a/pkg/analyzer/dns_dkim.go
+++ /dev/null
@@ -1,260 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// For AGPL licensing:
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package analyzer
-
-import (
- "context"
- "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
deleted file mode 100644
index 40e28a5..0000000
--- a/pkg/analyzer/dns_dkim_test.go
+++ /dev/null
@@ -1,409 +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 (
- "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
deleted file mode 100644
index b89500b..0000000
--- a/pkg/analyzer/dns_dmarc.go
+++ /dev/null
@@ -1,314 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// For AGPL licensing:
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package analyzer
-
-import (
- "context"
- "fmt"
- "net"
- "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
deleted file mode 100644
index 5c34a32..0000000
--- a/pkg/analyzer/dns_dmarc_test.go
+++ /dev/null
@@ -1,592 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// For AGPL licensing:
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package analyzer
-
-import (
- "context"
- "fmt"
- "net"
- "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
deleted file mode 100644
index 07e5ab9..0000000
--- a/pkg/analyzer/dns_fcr.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index c48c9a4..0000000
--- a/pkg/analyzer/dns_mx.go
+++ /dev/null
@@ -1,116 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index f60484f..0000000
--- a/pkg/analyzer/dns_resolver.go
+++ /dev/null
@@ -1,80 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index ccb1674..0000000
--- a/pkg/analyzer/dns_spf.go
+++ /dev/null
@@ -1,368 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// For AGPL licensing:
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package analyzer
-
-import (
- "context"
- "fmt"
- "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
deleted file mode 100644
index 2e794ce..0000000
--- a/pkg/analyzer/dns_spf_test.go
+++ /dev/null
@@ -1,284 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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 bba4503..12a6bd0 100644
--- a/pkg/analyzer/dns_test.go
+++ b/pkg/analyzer/dns_test.go
@@ -22,8 +22,12 @@
package analyzer
import (
+ "net/mail"
+ "strings"
"testing"
"time"
+
+ "git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
@@ -56,3 +60,761 @@ func TestNewDNSAnalyzer(t *testing.T) {
})
}
}
+
+func TestExtractDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ fromAddress string
+ expectedDomain string
+ }{
+ {
+ name: "Valid email",
+ fromAddress: "user@example.com",
+ expectedDomain: "example.com",
+ },
+ {
+ name: "Email with subdomain",
+ fromAddress: "user@mail.example.com",
+ expectedDomain: "mail.example.com",
+ },
+ {
+ name: "Email with uppercase",
+ fromAddress: "User@Example.COM",
+ expectedDomain: "example.com",
+ },
+ {
+ name: "Invalid email (no @)",
+ fromAddress: "invalid-email",
+ expectedDomain: "",
+ },
+ {
+ name: "Empty email",
+ fromAddress: "",
+ expectedDomain: "",
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ email := &EmailMessage{
+ Header: make(mail.Header),
+ }
+ if tt.fromAddress != "" {
+ email.From = &mail.Address{
+ Address: tt.fromAddress,
+ }
+ }
+
+ domain := analyzer.extractDomain(email)
+ if domain != tt.expectedDomain {
+ t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
+ }
+ })
+ }
+}
+
+func TestValidateSPF(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expected bool
+ }{
+ {
+ name: "Valid SPF with -all",
+ record: "v=spf1 include:_spf.example.com -all",
+ expected: true,
+ },
+ {
+ name: "Valid SPF with ~all",
+ record: "v=spf1 ip4:192.0.2.0/24 ~all",
+ expected: true,
+ },
+ {
+ name: "Valid SPF with +all",
+ record: "v=spf1 +all",
+ expected: true,
+ },
+ {
+ name: "Valid SPF with ?all",
+ record: "v=spf1 mx ?all",
+ expected: true,
+ },
+ {
+ name: "Invalid SPF - no version",
+ record: "include:_spf.example.com -all",
+ expected: false,
+ },
+ {
+ name: "Invalid SPF - no all mechanism",
+ record: "v=spf1 include:_spf.example.com",
+ expected: false,
+ },
+ {
+ name: "Invalid SPF - wrong version",
+ record: "v=spf2 include:_spf.example.com -all",
+ expected: false,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.validateSPF(tt.record)
+ if result != tt.expected {
+ t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestValidateDKIM(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expected bool
+ }{
+ {
+ name: "Valid DKIM with version",
+ record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
+ expected: true,
+ },
+ {
+ name: "Valid DKIM without version",
+ record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
+ expected: true,
+ },
+ {
+ name: "Invalid DKIM - no public key",
+ record: "v=DKIM1; k=rsa",
+ expected: false,
+ },
+ {
+ name: "Invalid DKIM - wrong version",
+ record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
+ expected: false,
+ },
+ {
+ name: "Invalid DKIM - empty",
+ record: "",
+ expected: false,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.validateDKIM(tt.record)
+ if result != tt.expected {
+ t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestExtractDMARCPolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expectedPolicy string
+ }{
+ {
+ name: "Policy none",
+ record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
+ expectedPolicy: "none",
+ },
+ {
+ name: "Policy quarantine",
+ record: "v=DMARC1; p=quarantine; pct=100",
+ expectedPolicy: "quarantine",
+ },
+ {
+ name: "Policy reject",
+ record: "v=DMARC1; p=reject; sp=reject",
+ expectedPolicy: "reject",
+ },
+ {
+ name: "No policy",
+ record: "v=DMARC1",
+ expectedPolicy: "unknown",
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.extractDMARCPolicy(tt.record)
+ if result != tt.expectedPolicy {
+ t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
+ }
+ })
+ }
+}
+
+func TestValidateDMARC(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expected bool
+ }{
+ {
+ name: "Valid DMARC",
+ record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
+ expected: true,
+ },
+ {
+ name: "Valid DMARC minimal",
+ record: "v=DMARC1; p=none",
+ expected: true,
+ },
+ {
+ name: "Invalid DMARC - no version",
+ record: "p=quarantine",
+ expected: false,
+ },
+ {
+ name: "Invalid DMARC - no policy",
+ record: "v=DMARC1",
+ expected: false,
+ },
+ {
+ name: "Invalid DMARC - wrong version",
+ record: "v=DMARC2; p=reject",
+ expected: false,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.validateDMARC(tt.record)
+ if result != tt.expected {
+ t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGenerateMXCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *DNSResults
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid MX records",
+ results: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Host: "mail.example.com", Priority: 10, Valid: true},
+ {Host: "mail2.example.com", Priority: 20, Valid: true},
+ },
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "No MX records",
+ results: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Valid: false, Error: "No MX records found"},
+ },
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ {
+ name: "MX lookup failed",
+ results: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Valid: false, Error: "DNS lookup failed"},
+ },
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateMXCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Dns {
+ t.Errorf("Category = %v, want %v", check.Category, api.Dns)
+ }
+ })
+ }
+}
+
+func TestGenerateSPFCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ spf *SPFRecord
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid SPF",
+ spf: &SPFRecord{
+ Record: "v=spf1 include:_spf.example.com -all",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "Invalid SPF",
+ spf: &SPFRecord{
+ Record: "v=spf1 invalid syntax",
+ Valid: false,
+ Error: "SPF record appears malformed",
+ },
+ expectedStatus: api.CheckStatusWarn,
+ expectedScore: 0.5,
+ },
+ {
+ name: "No SPF record",
+ spf: &SPFRecord{
+ Valid: false,
+ Error: "No SPF record found",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateSPFCheck(tt.spf)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Dns {
+ t.Errorf("Category = %v, want %v", check.Category, api.Dns)
+ }
+ })
+ }
+}
+
+func TestGenerateDKIMCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ dkim *DKIMRecord
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid DKIM",
+ dkim: &DKIMRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "Invalid DKIM",
+ dkim: &DKIMRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Valid: false,
+ Error: "No DKIM record found",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateDKIMCheck(tt.dkim)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Dns {
+ t.Errorf("Category = %v, want %v", check.Category, api.Dns)
+ }
+ if !strings.Contains(check.Name, tt.dkim.Selector) {
+ t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
+ }
+ })
+ }
+}
+
+func TestGenerateDMARCCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ dmarc *DMARCRecord
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid DMARC - reject",
+ dmarc: &DMARCRecord{
+ Record: "v=DMARC1; p=reject",
+ Policy: "reject",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "Valid DMARC - quarantine",
+ dmarc: &DMARCRecord{
+ Record: "v=DMARC1; p=quarantine",
+ Policy: "quarantine",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "Valid DMARC - none",
+ dmarc: &DMARCRecord{
+ Record: "v=DMARC1; p=none",
+ Policy: "none",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 1.0,
+ },
+ {
+ name: "No DMARC record",
+ dmarc: &DMARCRecord{
+ Valid: false,
+ Error: "No DMARC record found",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateDMARCCheck(tt.dmarc)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Dns {
+ t.Errorf("Category = %v, want %v", check.Category, api.Dns)
+ }
+
+ // Check that advice mentions policy for valid DMARC
+ if tt.dmarc.Valid && check.Advice != nil {
+ if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
+ t.Error("Advice should mention 'none' policy")
+ }
+ }
+ })
+ }
+}
+
+func TestGenerateDNSChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ results *DNSResults
+ minChecks int
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ minChecks: 0,
+ },
+ {
+ name: "Complete results",
+ results: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Host: "mail.example.com", Priority: 10, Valid: true},
+ },
+ SPFRecord: &SPFRecord{
+ Record: "v=spf1 include:_spf.example.com -all",
+ Valid: true,
+ },
+ DKIMRecords: []DKIMRecord{
+ {
+ Selector: "default",
+ Domain: "example.com",
+ Valid: true,
+ },
+ },
+ DMARCRecord: &DMARCRecord{
+ Record: "v=DMARC1; p=quarantine",
+ Policy: "quarantine",
+ Valid: true,
+ },
+ },
+ minChecks: 4, // MX, SPF, DKIM, DMARC
+ },
+ {
+ name: "Partial results",
+ results: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Host: "mail.example.com", Priority: 10, Valid: true},
+ },
+ },
+ minChecks: 1, // Only MX
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ checks := analyzer.GenerateDNSChecks(tt.results)
+
+ if len(checks) < tt.minChecks {
+ t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
+ }
+
+ // Verify all checks have the DNS category
+ for _, check := range checks {
+ if check.Category != api.Dns {
+ t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
+ }
+ }
+ })
+ }
+}
+
+func TestAnalyzeDNS_NoDomain(t *testing.T) {
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+ email := &EmailMessage{
+ Header: make(mail.Header),
+ // No From address
+ }
+
+ results := analyzer.AnalyzeDNS(email, nil)
+
+ if results == nil {
+ t.Fatal("Expected results, got nil")
+ }
+
+ if len(results.Errors) == 0 {
+ t.Error("Expected error when no domain can be extracted")
+ }
+}
+
+func TestExtractBIMITag(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ tag string
+ expectedValue string
+ }{
+ {
+ name: "Extract logo URL (l tag)",
+ record: "v=BIMI1; l=https://example.com/logo.svg",
+ tag: "l",
+ expectedValue: "https://example.com/logo.svg",
+ },
+ {
+ name: "Extract VMC URL (a tag)",
+ record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
+ tag: "a",
+ expectedValue: "https://example.com/vmc.pem",
+ },
+ {
+ name: "Tag not found",
+ record: "v=BIMI1; l=https://example.com/logo.svg",
+ tag: "a",
+ expectedValue: "",
+ },
+ {
+ name: "Tag with spaces",
+ record: "v=BIMI1; l= https://example.com/logo.svg ",
+ tag: "l",
+ expectedValue: "https://example.com/logo.svg",
+ },
+ {
+ name: "Empty record",
+ record: "",
+ tag: "l",
+ expectedValue: "",
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.extractBIMITag(tt.record, tt.tag)
+ if result != tt.expectedValue {
+ t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
+ }
+ })
+ }
+}
+
+func TestValidateBIMI(t *testing.T) {
+ tests := []struct {
+ name string
+ record string
+ expected bool
+ }{
+ {
+ name: "Valid BIMI with logo URL",
+ record: "v=BIMI1; l=https://example.com/logo.svg",
+ expected: true,
+ },
+ {
+ name: "Valid BIMI with logo and VMC",
+ record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
+ expected: true,
+ },
+ {
+ name: "Invalid BIMI - no version",
+ record: "l=https://example.com/logo.svg",
+ expected: false,
+ },
+ {
+ name: "Invalid BIMI - wrong version",
+ record: "v=BIMI2; l=https://example.com/logo.svg",
+ expected: false,
+ },
+ {
+ name: "Invalid BIMI - no logo URL",
+ record: "v=BIMI1",
+ expected: false,
+ },
+ {
+ name: "Invalid BIMI - empty",
+ record: "",
+ expected: false,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := analyzer.validateBIMI(tt.record)
+ if result != tt.expected {
+ t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGenerateBIMICheck(t *testing.T) {
+ tests := []struct {
+ name string
+ bimi *BIMIRecord
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Valid BIMI with logo only",
+ bimi: &BIMIRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Record: "v=BIMI1; l=https://example.com/logo.svg",
+ LogoURL: "https://example.com/logo.svg",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 0.0, // BIMI doesn't contribute to score
+ },
+ {
+ name: "Valid BIMI with VMC",
+ bimi: &BIMIRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
+ LogoURL: "https://example.com/logo.svg",
+ VMCURL: "https://example.com/vmc.pem",
+ Valid: true,
+ },
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 0.0,
+ },
+ {
+ name: "No BIMI record (optional)",
+ bimi: &BIMIRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Valid: false,
+ Error: "No BIMI record found",
+ },
+ expectedStatus: api.CheckStatusInfo,
+ expectedScore: 0.0,
+ },
+ {
+ name: "Invalid BIMI record",
+ bimi: &BIMIRecord{
+ Selector: "default",
+ Domain: "example.com",
+ Record: "v=BIMI1",
+ Valid: false,
+ Error: "BIMI record appears malformed",
+ },
+ expectedStatus: api.CheckStatusWarn,
+ expectedScore: 0.0,
+ },
+ }
+
+ analyzer := NewDNSAnalyzer(5 * time.Second)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateBIMICheck(tt.bimi)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Dns {
+ t.Errorf("Category = %v, want %v", check.Category, api.Dns)
+ }
+ if check.Name != "BIMI Record" {
+ t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
+ }
+
+ // Check details for valid BIMI with VMC
+ if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
+ if !strings.Contains(*check.Details, "VMC URL") {
+ t.Error("Details should contain VMC URL for valid BIMI with VMC")
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go
deleted file mode 100644
index 6d7b547..0000000
--- a/pkg/analyzer/headers.go
+++ /dev/null
@@ -1,697 +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"
- "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
deleted file mode 100644
index d7469d7..0000000
--- a/pkg/analyzer/headers_test.go
+++ /dev/null
@@ -1,1079 +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/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 00de151..13c012c 100644
--- a/pkg/analyzer/parser.go
+++ b/pkg/analyzer/parser.go
@@ -211,27 +211,8 @@ func buildRawHeaders(header mail.Header) string {
}
// GetAuthenticationResults extracts Authentication-Results headers
-// 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
+func (e *EmailMessage) GetAuthenticationResults() []string {
+ return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
}
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
@@ -249,33 +230,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
}
for _, headerName := range saHeaders {
- if values, ok := e.Header[headerName]; ok && len(values) > 0 {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- headers[headerName] = value
- break
- }
- }
- } else if value := e.Header.Get(headerName); value != "" {
- headers[headerName] = value
- }
- }
-
- return headers
-}
-
-// GetRspamdHeaders extracts rspamd-related headers
-func (e *EmailMessage) GetRspamdHeaders() map[string]string {
- headers := make(map[string]string)
-
- rspamdHeaders := []string{
- "X-Spamd-Result",
- "X-Rspamd-Score",
- "X-Rspamd-Action",
- "X-Rspamd-Server",
- }
-
- for _, headerName := range rspamdHeaders {
if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
@@ -321,20 +275,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
func (e *EmailMessage) HasHeader(key string) bool {
return e.Header.Get(key) != ""
}
-
-// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
-// The header format is: , , ...
-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 196e8e2..571f542 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("example.com")
+ authResults := email.GetAuthenticationResults()
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 31cccab..fb01ae0 100644
--- a/pkg/analyzer/rbl.go
+++ b/pkg/analyzer/rbl.go
@@ -27,22 +27,16 @@ import (
"net"
"regexp"
"strings"
- "sync"
"time"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
)
-// DNSListChecker checks IP addresses against DNS-based block/allow lists.
-// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
-type DNSListChecker struct {
- Timeout time.Duration
- Lists []string
- CheckAllIPs bool // Check all IPs found in headers, not just the first one
- filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
- resolver *net.Resolver
- informationalSet map[string]bool // Lists whose hits don't count toward the score
+// RBLChecker checks IP addresses against DNS-based blacklists
+type RBLChecker struct {
+ Timeout time.Duration
+ RBLs []string
+ resolver *net.Resolver
}
// DefaultRBLs is a list of commonly used RBL providers
@@ -53,83 +47,46 @@ var DefaultRBLs = []string{
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
- "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
- "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
- "psbl.surriel.com", // PSBL
- "dnsbl.dronebl.org", // DroneBL
- "bl.mailspike.net", // Mailspike BL
- "z.mailspike.net", // Mailspike Z
- "bl.rbl-dns.com", // RBL-DNS
- "bl.nszones.com", // NSZones
-}
-
-// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
-// These are typically broader lists where being listed is less definitive.
-var DefaultInformationalRBLs = []string{
- "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
- "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
-}
-
-// DefaultDNSWLs is a list of commonly used DNSWL providers
-var DefaultDNSWLs = []string{
- "list.dnswl.org", // DNSWL.org — the main DNS whitelist
- "swl.spamhaus.org", // Spamhaus Safe Whitelist
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
-func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
+func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
if timeout == 0 {
- timeout = 5 * time.Second
+ timeout = 5 * time.Second // Default timeout
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
- informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
- for _, rbl := range DefaultInformationalRBLs {
- informationalSet[rbl] = true
- }
- return &DNSListChecker{
- Timeout: timeout,
- Lists: rbls,
- CheckAllIPs: checkAllIPs,
- filterErrorCodes: true,
- resolver: &net.Resolver{PreferGo: true},
- informationalSet: informationalSet,
+ return &RBLChecker{
+ Timeout: timeout,
+ RBLs: rbls,
+ resolver: &net.Resolver{
+ PreferGo: true,
+ },
}
}
-// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
-func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
- if timeout == 0 {
- timeout = 5 * time.Second
- }
- if len(dnswls) == 0 {
- dnswls = DefaultDNSWLs
- }
- return &DNSListChecker{
- Timeout: timeout,
- Lists: dnswls,
- CheckAllIPs: checkAllIPs,
- filterErrorCodes: false,
- resolver: &net.Resolver{PreferGo: true},
- informationalSet: make(map[string]bool),
- }
+// RBLResults represents the results of RBL checks
+type RBLResults struct {
+ Checks []RBLCheck
+ IPsChecked []string
+ ListedCount int
}
-// DNSListResults represents the results of DNS list checks
-type DNSListResults struct {
- Checks map[string][]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
+// RBLCheck represents a single RBL check result
+type RBLCheck struct {
+ IP string
+ RBL string
+ Listed bool
+ Response string
+ Error string
}
-// CheckEmail checks all IPs found in the email headers against the configured lists
-func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
- results := &DNSListResults{
- Checks: make(map[string][]model.BlacklistCheck),
- }
+// CheckEmail checks all IPs found in the email headers against RBLs
+func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
+ results := &RBLResults{}
+ // Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
@@ -137,68 +94,42 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results.IPsChecked = ips
+ // Check each IP against all RBLs
for _, ip := range ips {
- for _, list := range r.Lists {
- check := r.checkIP(ip, list)
- results.Checks[ip] = append(results.Checks[ip], check)
+ for _, rbl := range r.RBLs {
+ check := r.checkIP(ip, rbl)
+ results.Checks = append(results.Checks, check)
if check.Listed {
results.ListedCount++
- if !r.informationalSet[list] {
- results.RelevantListedCount++
- }
}
}
-
- if !r.CheckAllIPs {
- break
- }
}
return results
}
-// CheckIP checks a single IP address against all configured lists in parallel
-func (r *DNSListChecker) CheckIP(ip string) ([]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 *DNSListChecker) extractIPs(email *EmailMessage) []string {
+func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
+ // Get all Received headers
receivedHeaders := email.Header["Received"]
+
+ // Regex patterns for IP addresses
+ // Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
+ // Look for IPs in Received headers
for _, received := range receivedHeaders {
+ // Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
+ // Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
+ // Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
@@ -206,10 +137,13 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
}
}
+ // If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
+ // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
+ // Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
@@ -222,16 +156,19 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
-func (r *DNSListChecker) isPublicIP(ipStr string) bool {
+func (r *RBLChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
+ // Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
+ // Additional checks for reserved ranges
+ // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
@@ -239,120 +176,233 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
return true
}
-// checkIP checks a single IP against a single DNS list
-func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
- check := model.BlacklistCheck{
- Rbl: list,
+// checkIP checks a single IP against a single RBL
+func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
+ check := RBLCheck{
+ IP: ip,
+ RBL: rbl,
}
+ // Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
- check.Error = utils.PtrTo("Failed to reverse IP address")
+ check.Error = "Failed to reverse IP address"
return check
}
- query := fmt.Sprintf("%s.%s", reversedIP, list)
+ // Construct DNSBL query: reversed-ip.rbl-domain
+ query := fmt.Sprintf("%s.%s", reversedIP, rbl)
+ // Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
+ // Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
- check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
+ // Other DNS errors
+ check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
return check
}
+ // If we got a response, the IP is listed
if len(addrs) > 0 {
- check.Response = 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
- }
+ check.Listed = true
+ check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
}
return check
}
-// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
+// reverseIP reverses an IPv4 address for DNSBL queries
// Example: 192.0.2.1 -> 1.2.0.192
-func (r *DNSListChecker) reverseIP(ipStr string) string {
+func (r *RBLChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
+ // Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
+ // Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
-// CalculateScore calculates the list contribution to deliverability.
-// Informational lists are not counted in the score.
-func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
- scoringListCount := len(r.Lists) - len(r.informationalSet)
+// 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
+ }
- if forWhitelist {
- if results.ListedCount >= scoringListCount {
- return 100, "A++"
- } else if results.ListedCount > 0 {
- return 100, "A+"
- } else {
- return 95, "A"
+ 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 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)
+ return checks
}
-// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
-func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
+// generateSummaryCheck creates an overall RBL summary check
+func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
+ check := api.Check{
+ Category: api.Blacklist,
+ Name: "RBL Summary",
+ }
+
+ score := r.GetBlacklistScore(results)
+ check.Score = score
+
+ totalChecks := len(results.Checks)
+ listedCount := results.ListedCount
+
+ if listedCount == 0 {
+ check.Status = api.CheckStatusPass
+ check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Advice = api.PtrTo("Your sending IP has a good reputation")
+ } else if listedCount == 1 {
+ check.Status = api.CheckStatusWarn
+ check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
+ } else if listedCount <= 3 {
+ check.Status = api.CheckStatusWarn
+ check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
+ } else {
+ check.Status = api.CheckStatusFail
+ check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
+ check.Severity = api.PtrTo(api.CheckSeverityCritical)
+ check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
+ }
+
+ // Add details about IPs checked
+ if len(results.IPsChecked) > 0 {
+ details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
+ check.Details = &details
+ }
+
+ return check
+}
+
+// generateListingCheck creates a check for a specific RBL listing
+func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
+ check := api.Check{
+ Category: api.Blacklist,
+ Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
+ Status: api.CheckStatusFail,
+ Score: 0.0,
+ }
+
+ check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
+
+ // Determine severity based on which RBL
+ if strings.Contains(rblCheck.RBL, "spamhaus") {
+ check.Severity = api.PtrTo(api.CheckSeverityCritical)
+ advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
+ check.Advice = &advice
+ } else if strings.Contains(rblCheck.RBL, "spamcop") {
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
+ check.Advice = &advice
+ } else {
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
+ check.Advice = &advice
+ }
+
+ // Add response code details
+ if rblCheck.Response != "" {
+ details := fmt.Sprintf("Response: %s", rblCheck.Response)
+ check.Details = &details
+ }
+
+ return check
+}
+
+// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
+func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
+ seenIPs := make(map[string]bool)
var listedIPs []string
- for ip, checks := range results.Checks {
- for _, check := range checks {
- if check.Listed {
- listedIPs = append(listedIPs, ip)
- break
- }
+ for _, check := range results.Checks {
+ if check.Listed && !seenIPs[check.IP] {
+ listedIPs = append(listedIPs, check.IP)
+ seenIPs[check.IP] = true
}
}
return listedIPs
}
-// GetListsForIP returns all lists that match a specific IP
-func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
- var lists []string
+// GetRBLsForIP returns all RBLs that list a specific IP
+func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
+ var rbls []string
- if checks, exists := results.Checks[ip]; exists {
- for _, check := range checks {
- if check.Listed {
- lists = append(lists, check.Rbl)
- }
+ for _, check := range results.Checks {
+ if check.IP == ip && check.Listed {
+ rbls = append(rbls, check.RBL)
}
}
- return lists
+ return rbls
}
diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go
index f86f17b..3a2fd44 100644
--- a/pkg/analyzer/rbl_test.go
+++ b/pkg/analyzer/rbl_test.go
@@ -23,10 +23,11 @@ package analyzer
import (
"net/mail"
+ "strings"
"testing"
"time"
- "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/api"
)
func TestNewRBLChecker(t *testing.T) {
@@ -55,12 +56,12 @@ func TestNewRBLChecker(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- checker := NewRBLChecker(tt.timeout, tt.rbls, false)
+ checker := NewRBLChecker(tt.timeout, tt.rbls)
if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
}
- if len(checker.Lists) != tt.expectedRBLs {
- t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
+ if len(checker.RBLs) != tt.expectedRBLs {
+ t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
}
if checker.resolver == nil {
t.Error("Resolver should not be nil")
@@ -97,7 +98,7 @@ func TestReverseIP(t *testing.T) {
},
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -157,7 +158,7 @@ func TestIsPublicIP(t *testing.T) {
},
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -237,7 +238,7 @@ func TestExtractIPs(t *testing.T) {
},*/
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -265,72 +266,68 @@ func TestExtractIPs(t *testing.T) {
func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
- results *DNSListResults
- expectedScore int
+ results *RBLResults
+ expectedScore float32
}{
{
name: "Nil results",
results: nil,
- expectedScore: 100,
+ expectedScore: 2.0,
},
{
name: "No IPs checked",
- results: &DNSListResults{
+ results: &RBLResults{
IPsChecked: []string{},
},
- expectedScore: 100,
+ expectedScore: 2.0,
},
{
name: "Not listed on any RBL",
- results: &DNSListResults{
+ results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
- expectedScore: 100,
+ expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
- results: &DNSListResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 1,
- RelevantListedCount: 1,
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 1,
},
- expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
+ expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
- results: &DNSListResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 2,
- RelevantListedCount: 2,
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 2,
},
- expectedScore: 84, // 100 - 2*100/12 = 84
+ expectedScore: 0.5,
},
{
name: "Listed on 3 RBLs",
- results: &DNSListResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 3,
- RelevantListedCount: 3,
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 3,
},
- expectedScore: 75, // 100 - 3*100/12 = 75
+ expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
- results: &DNSListResults{
- IPsChecked: []string{"198.51.100.1"},
- ListedCount: 4,
- RelevantListedCount: 4,
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 4,
},
- expectedScore: 67, // 100 - 4*100/12 = 67
+ expectedScore: 0.0,
},
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- score, _ := checker.CalculateScore(tt.results, false)
+ score := checker.GetBlacklistScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@@ -338,24 +335,215 @@ func TestGetBlacklistScore(t *testing.T) {
}
}
-func TestGetUniqueListedIPs(t *testing.T) {
- results := &DNSListResults{
- Checks: map[string][]model.BlacklistCheck{
- "198.51.100.1": {
- {Rbl: "zen.spamhaus.org", Listed: true},
- {Rbl: "bl.spamcop.net", Listed: true},
+func TestGenerateSummaryCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ results *RBLResults
+ expectedStatus api.CheckStatus
+ expectedScore float32
+ }{
+ {
+ name: "Not listed",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 0,
+ Checks: make([]RBLCheck, 6), // 6 default RBLs
},
- "198.51.100.2": {
- {Rbl: "zen.spamhaus.org", Listed: true},
- {Rbl: "bl.spamcop.net", Listed: false},
+ expectedStatus: api.CheckStatusPass,
+ expectedScore: 2.0,
+ },
+ {
+ name: "Listed on 1 RBL",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 1,
+ Checks: make([]RBLCheck, 6),
},
- "198.51.100.3": {
- {Rbl: "zen.spamhaus.org", Listed: false},
+ expectedStatus: api.CheckStatusWarn,
+ expectedScore: 1.0,
+ },
+ {
+ name: "Listed on 2 RBLs",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 2,
+ Checks: make([]RBLCheck, 6),
},
+ expectedStatus: api.CheckStatusWarn,
+ expectedScore: 0.5,
+ },
+ {
+ name: "Listed on 4+ RBLs",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 4,
+ Checks: make([]RBLCheck, 6),
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedScore: 0.0,
},
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := checker.generateSummaryCheck(tt.results)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Score != tt.expectedScore {
+ t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
+ }
+ if check.Category != api.Blacklist {
+ t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
+ }
+ })
+ }
+}
+
+func TestGenerateListingCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ rblCheck *RBLCheck
+ expectedStatus api.CheckStatus
+ expectedSeverity api.CheckSeverity
+ }{
+ {
+ name: "Spamhaus listing",
+ rblCheck: &RBLCheck{
+ IP: "198.51.100.1",
+ RBL: "zen.spamhaus.org",
+ Listed: true,
+ Response: "127.0.0.2",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedSeverity: api.CheckSeverityCritical,
+ },
+ {
+ name: "SpamCop listing",
+ rblCheck: &RBLCheck{
+ IP: "198.51.100.1",
+ RBL: "bl.spamcop.net",
+ Listed: true,
+ Response: "127.0.0.2",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedSeverity: api.CheckSeverityHigh,
+ },
+ {
+ name: "Other RBL listing",
+ rblCheck: &RBLCheck{
+ IP: "198.51.100.1",
+ RBL: "dnsbl.sorbs.net",
+ Listed: true,
+ Response: "127.0.0.2",
+ },
+ expectedStatus: api.CheckStatusFail,
+ expectedSeverity: api.CheckSeverityHigh,
+ },
+ }
+
+ checker := NewRBLChecker(5*time.Second, nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := checker.generateListingCheck(tt.rblCheck)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Severity == nil || *check.Severity != tt.expectedSeverity {
+ t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
+ }
+ if check.Category != api.Blacklist {
+ t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
+ }
+ if !strings.Contains(check.Name, tt.rblCheck.RBL) {
+ t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
+ }
+ })
+ }
+}
+
+func TestGenerateRBLChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ results *RBLResults
+ minChecks int
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ minChecks: 0,
+ },
+ {
+ name: "No IPs checked",
+ results: &RBLResults{
+ IPsChecked: []string{},
+ },
+ minChecks: 1, // Warning check
+ },
+ {
+ name: "Not listed on any RBL",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 0,
+ Checks: []RBLCheck{
+ {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
+ {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
+ },
+ },
+ minChecks: 1, // Summary check only
+ },
+ {
+ name: "Listed on 2 RBLs",
+ results: &RBLResults{
+ IPsChecked: []string{"198.51.100.1"},
+ ListedCount: 2,
+ Checks: []RBLCheck{
+ {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
+ {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
+ {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
+ },
+ },
+ minChecks: 3, // Summary + 2 listing checks
+ },
+ }
+
+ checker := NewRBLChecker(5*time.Second, nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ checks := checker.GenerateRBLChecks(tt.results)
+
+ if len(checks) < tt.minChecks {
+ t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
+ }
+
+ // Verify all checks have the Blacklist category
+ for _, check := range checks {
+ if check.Category != api.Blacklist {
+ t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
+ }
+ }
+ })
+ }
+}
+
+func TestGetUniqueListedIPs(t *testing.T) {
+ results := &RBLResults{
+ Checks: []RBLCheck{
+ {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
+ {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
+ {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
+ {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
+ {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
+ },
+ }
+
+ checker := NewRBLChecker(5*time.Second, nil)
listedIPs := checker.GetUniqueListedIPs(results)
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
@@ -367,20 +555,16 @@ func TestGetUniqueListedIPs(t *testing.T) {
}
func TestGetRBLsForIP(t *testing.T) {
- 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},
- },
+ results := &RBLResults{
+ Checks: []RBLCheck{
+ {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
+ {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
+ {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
+ {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
},
}
- checker := NewRBLChecker(5*time.Second, nil, false)
+ checker := NewRBLChecker(5*time.Second, nil)
tests := []struct {
name string
@@ -406,7 +590,7 @@ func TestGetRBLsForIP(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- rbls := checker.GetListsForIP(results, tt.ip)
+ rbls := checker.GetRBLsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go
index 26cd59d..fe30c6c 100644
--- a/pkg/analyzer/report.go
+++ b/pkg/analyzer/report.go
@@ -24,8 +24,7 @@ package analyzer
import (
"time"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
@@ -33,47 +32,37 @@ import (
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
- rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
- rblChecker *DNSListChecker
- dnswlChecker *DNSListChecker
+ rblChecker *RBLChecker
contentAnalyzer *ContentAnalyzer
- headerAnalyzer *HeaderAnalyzer
+ scorer *DeliverabilityScorer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
- receiverHostname string,
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
- dnswls []string,
- checkAllIPs bool,
- rspamdAPIURL string,
) *ReportGenerator {
return &ReportGenerator{
- authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
+ authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
- rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
- rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
- dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
+ rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
- headerAnalyzer: NewHeaderAnalyzer(),
+ scorer: NewDeliverabilityScorer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
- Authentication *model.AuthenticationResults
+ Authentication *api.AuthenticationResults
+ SpamAssassin *SpamAssassinResult
+ DNS *DNSResults
+ RBL *RBLResults
Content *ContentResults
- DNS *model.DNSResults
- Headers *model.HeaderAnalysis
- RBL *DNSListResults
- DNSWL *DNSListResults
- SpamAssassin *model.SpamAssassinResult
- Rspamd *model.RspamdResult
+ Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
@@ -84,217 +73,248 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
- results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
- results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
- results.RBL = r.rblChecker.CheckEmail(email)
- results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
- results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
+ results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
+ results.RBL = r.rblChecker.CheckEmail(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
+ // Calculate overall score
+ results.Score = r.scorer.CalculateScore(
+ results.Authentication,
+ results.SpamAssassin,
+ results.RBL,
+ results.Content,
+ email,
+ )
+
return results
}
// GenerateReport creates a complete API report from analysis results
-func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
+func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
reportID := uuid.New()
now := time.Now()
- report := &model.Report{
- Id: utils.UUIDToBase32(reportID),
- TestId: utils.UUIDToBase32(testID),
+ report := &api.Report{
+ Id: reportID,
+ TestId: testID,
+ Score: results.Score.OverallScore,
CreatedAt: now,
}
- // Calculate scores directly from analyzers (no more checks array)
- dnsScore := 0
- var dnsGrade string
- if results.DNS != nil {
- // Extract sender IP from received chain for FCrDNS verification
- var senderIP string
- if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
- firstHop := (*results.Headers.ReceivedChain)[0]
- if firstHop.Ip != nil {
- senderIP = *firstHop.Ip
- }
- }
- dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
+ // 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,
}
- authScore := 0
- var authGrade string
+ // Collect all checks from different analyzers
+ checks := []api.Check{}
+
+ // Authentication checks
if results.Authentication != nil {
- authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
+ authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
+ checks = append(checks, authChecks...)
}
- contentScore := 0
- var contentGrade string
- if results.Content != nil {
- contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
+ // DNS checks
+ if results.DNS != nil {
+ dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
+ checks = append(checks, dnsChecks...)
}
- headerScore := 0
- var headerGrade rune
- if results.Headers != nil {
- headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
- }
-
- blacklistScore := 0
- var blacklistGrade string
- var whitelistGrade string
+ // RBL checks
if results.RBL != nil {
- blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
- _, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
+ rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
+ checks = append(checks, rblChecks...)
}
- 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)
+ // SpamAssassin checks
+ if results.SpamAssassin != nil {
+ spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
+ checks = append(checks, spamChecks...)
}
- 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),
+ // Content checks
+ if results.Content != nil {
+ contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
+ checks = append(checks, contentChecks...)
}
+ // Header checks
+ headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
+ checks = append(checks, headerChecks...)
+
+ report.Checks = checks
+
// Add authentication results
report.Authentication = results.Authentication
- // Add content analysis
- if results.Content != nil {
- contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
- report.ContentAnalysis = contentAnalysis
+ // Add SpamAssassin result
+ if results.SpamAssassin != nil {
+ report.Spamassassin = &api.SpamAssassinResult{
+ Score: float32(results.SpamAssassin.Score),
+ RequiredScore: float32(results.SpamAssassin.RequiredScore),
+ IsSpam: results.SpamAssassin.IsSpam,
+ }
+
+ if len(results.SpamAssassin.Tests) > 0 {
+ report.Spamassassin.Tests = &results.SpamAssassin.Tests
+ }
+
+ if results.SpamAssassin.RawReport != "" {
+ report.Spamassassin.Report = &results.SpamAssassin.RawReport
+ }
}
// Add DNS records
if results.DNS != nil {
- report.DnsResults = results.DNS
+ dnsRecords := r.buildDNSRecords(results.DNS)
+ if len(dnsRecords) > 0 {
+ report.DnsRecords = &dnsRecords
+ }
}
- // Add headers results
- report.HeaderAnalysis = results.Headers
-
- // Add blacklist checks as a map of IP -> array of BlacklistCheck
+ // Add blacklist checks
if results.RBL != nil && len(results.RBL.Checks) > 0 {
- report.Blacklists = &results.RBL.Checks
+ blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks))
+ for _, check := range results.RBL.Checks {
+ blCheck := api.BlacklistCheck{
+ Ip: check.IP,
+ Rbl: check.RBL,
+ Listed: check.Listed,
+ }
+ if check.Response != "" {
+ blCheck.Response = &check.Response
+ }
+ blacklistChecks = append(blacklistChecks, blCheck)
+ }
+ report.Blacklists = &blacklistChecks
}
- // Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
- if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
- report.Whitelists = &results.DNSWL.Checks
- }
-
- // Add SpamAssassin result with individual deliverability score
- if results.SpamAssassin != nil {
- saGradeTyped := 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 {
@@ -308,3 +328,21 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
return raw
}
+
+// GetRecommendations returns actionable recommendations based on the score
+func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
+ if results == nil || results.Score == nil {
+ return []string{}
+ }
+
+ return results.Score.Recommendations
+}
+
+// GetScoreSummaryText returns a human-readable score summary
+func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
+ if results == nil || results.Score == nil {
+ return ""
+ }
+
+ return r.scorer.GetScoreSummary(results.Score)
+}
diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go
index 5914737..4a8fe00 100644
--- a/pkg/analyzer/report_test.go
+++ b/pkg/analyzer/report_test.go
@@ -24,15 +24,16 @@ package analyzer
import (
"net/mail"
"net/textproto"
+ "strings"
"testing"
"time"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
func TestNewReportGenerator(t *testing.T) {
- gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
@@ -52,10 +53,13 @@ func TestNewReportGenerator(t *testing.T) {
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
+ if gen.scorer == nil {
+ t.Error("scorer should not be nil")
+ }
}
func TestAnalyzeEmail(t *testing.T) {
- gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
email := createTestEmail()
@@ -72,10 +76,24 @@ func TestAnalyzeEmail(t *testing.T) {
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
+
+ // SpamAssassin might be nil if headers don't exist
+ // DNS results should exist
+ // RBL results should exist
+ // Content results should exist
+
+ if results.Score == nil {
+ t.Error("Score should not be nil")
+ }
+
+ // Verify score is within bounds
+ if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
+ t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
+ }
}
func TestGenerateReport(t *testing.T) {
- gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmail()
@@ -88,17 +106,15 @@ func TestGenerateReport(t *testing.T) {
}
// Verify required fields
- if report.Id == "" {
+ if report.Id == uuid.Nil {
t.Error("Report ID should not be empty")
}
- // Convert testID to base32 for comparison
- expectedTestID := utils.UUIDToBase32(testID)
- if report.TestId != expectedTestID {
- t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
+ if report.TestId != testID {
+ t.Errorf("TestId = %s, want %s", report.TestId, testID)
}
- if report.Score < 0 || report.Score > 100 {
+ if report.Score < 0 || report.Score > 10 {
t.Errorf("Score %v is out of bounds", report.Score)
}
@@ -106,31 +122,48 @@ func TestGenerateReport(t *testing.T) {
t.Error("Summary should not be nil")
}
- // Verify score summary (all scores are 0-100 percentages)
+ if len(report.Checks) == 0 {
+ t.Error("Checks should not be empty")
+ }
+
+ // Verify score summary
if report.Summary != nil {
- if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 {
+ if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
}
- if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 {
+ if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 {
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
}
- if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 {
+ if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 {
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
}
- if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 {
+ if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 {
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
}
- if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
+ if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 {
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
}
- if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 {
- t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore)
+ }
+
+ // Verify checks have required fields
+ for i, check := range report.Checks {
+ if string(check.Category) == "" {
+ t.Errorf("Check %d: Category should not be empty", i)
+ }
+ if check.Name == "" {
+ t.Errorf("Check %d: Name should not be empty", i)
+ }
+ if string(check.Status) == "" {
+ t.Errorf("Check %d: Status should not be empty", i)
+ }
+ if check.Message == "" {
+ t.Errorf("Check %d: Message should not be empty", i)
}
}
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
- gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
@@ -149,8 +182,101 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
}
}
+func TestBuildDNSRecords(t *testing.T) {
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+
+ tests := []struct {
+ name string
+ dns *DNSResults
+ expectedCount int
+ expectTypes []api.DNSRecordRecordType
+ }{
+ {
+ name: "Nil DNS results",
+ dns: nil,
+ expectedCount: 0,
+ },
+ {
+ name: "Complete DNS results",
+ dns: &DNSResults{
+ Domain: "example.com",
+ MXRecords: []MXRecord{
+ {Host: "mail.example.com", Priority: 10, Valid: true},
+ },
+ SPFRecord: &SPFRecord{
+ Record: "v=spf1 include:_spf.example.com -all",
+ Valid: true,
+ },
+ DKIMRecords: []DKIMRecord{
+ {
+ Selector: "default",
+ Domain: "example.com",
+ Record: "v=DKIM1; k=rsa; p=...",
+ Valid: true,
+ },
+ },
+ DMARCRecord: &DMARCRecord{
+ Record: "v=DMARC1; p=quarantine",
+ Valid: true,
+ },
+ },
+ expectedCount: 4, // MX, SPF, DKIM, DMARC
+ expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
+ },
+ {
+ name: "Missing records",
+ dns: &DNSResults{
+ Domain: "example.com",
+ SPFRecord: &SPFRecord{
+ Valid: false,
+ Error: "No SPF record found",
+ },
+ },
+ expectedCount: 1,
+ expectTypes: []api.DNSRecordRecordType{api.SPF},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ records := gen.buildDNSRecords(tt.dns)
+
+ if len(records) != tt.expectedCount {
+ t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
+ }
+
+ // Verify expected types are present
+ if tt.expectTypes != nil {
+ foundTypes := make(map[api.DNSRecordRecordType]bool)
+ for _, record := range records {
+ foundTypes[record.RecordType] = true
+ }
+
+ for _, expectedType := range tt.expectTypes {
+ if !foundTypes[expectedType] {
+ t.Errorf("Expected DNS record type %s not found", expectedType)
+ }
+ }
+ }
+
+ // Verify all records have required fields
+ for i, record := range records {
+ if record.Domain == "" {
+ t.Errorf("Record %d: Domain should not be empty", i)
+ }
+ if string(record.RecordType) == "" {
+ t.Errorf("Record %d: RecordType should not be empty", i)
+ }
+ if string(record.Status) == "" {
+ t.Errorf("Record %d: Status should not be empty", i)
+ }
+ }
+ })
+ }
+}
+
func TestGenerateRawEmail(t *testing.T) {
- gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
@@ -190,6 +316,135 @@ func TestGenerateRawEmail(t *testing.T) {
}
}
+func TestGetRecommendations(t *testing.T) {
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+
+ tests := []struct {
+ name string
+ results *AnalysisResults
+ expectCount int
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ expectCount: 0,
+ },
+ {
+ name: "Results with score",
+ results: &AnalysisResults{
+ Score: &ScoringResult{
+ OverallScore: 5.0,
+ Rating: "Fair",
+ AuthScore: 1.5,
+ SpamScore: 1.0,
+ BlacklistScore: 1.5,
+ ContentScore: 0.5,
+ HeaderScore: 0.5,
+ Recommendations: []string{
+ "Improve authentication",
+ "Fix content issues",
+ },
+ },
+ },
+ expectCount: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ recs := gen.GetRecommendations(tt.results)
+ if len(recs) != tt.expectCount {
+ t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
+ }
+ })
+ }
+}
+
+func TestGetScoreSummaryText(t *testing.T) {
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+
+ tests := []struct {
+ name string
+ results *AnalysisResults
+ expectEmpty bool
+ expectString string
+ }{
+ {
+ name: "Nil results",
+ results: nil,
+ expectEmpty: true,
+ },
+ {
+ name: "Results with score",
+ results: &AnalysisResults{
+ Score: &ScoringResult{
+ OverallScore: 8.5,
+ Rating: "Good",
+ AuthScore: 2.5,
+ SpamScore: 1.8,
+ BlacklistScore: 2.0,
+ ContentScore: 1.5,
+ HeaderScore: 0.7,
+ CategoryBreakdown: map[string]CategoryScore{
+ "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
+ "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
+ "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
+ "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
+ "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
+ },
+ },
+ },
+ expectEmpty: false,
+ expectString: "8.5/10",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ summary := gen.GetScoreSummaryText(tt.results)
+ if tt.expectEmpty {
+ if summary != "" {
+ t.Errorf("Expected empty summary, got %q", summary)
+ }
+ } else {
+ if summary == "" {
+ t.Error("Expected non-empty summary")
+ }
+ if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
+ t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
+ }
+ }
+ })
+ }
+}
+
+func TestReportCategories(t *testing.T) {
+ gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
+ testID := uuid.New()
+
+ email := createComprehensiveTestEmail()
+ results := gen.AnalyzeEmail(email)
+ report := gen.GenerateReport(testID, results)
+
+ // Verify all check categories are present
+ categories := make(map[api.CheckCategory]bool)
+ for _, check := range report.Checks {
+ categories[check.Category] = true
+ }
+
+ expectedCategories := []api.CheckCategory{
+ api.Authentication,
+ api.Dns,
+ api.Headers,
+ }
+
+ for _, cat := range expectedCategories {
+ if !categories[cat] {
+ t.Errorf("Expected category %s not found in checks", cat)
+ }
+ }
+}
+
// Helper functions
func createTestEmail() *EmailMessage {
@@ -226,3 +481,21 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}
+
+func createComprehensiveTestEmail() *EmailMessage {
+ email := createTestEmailWithSpamAssassin()
+
+ // Add authentication headers
+ email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
+ "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
+ }
+
+ // Add HTML content
+ email.Parts = append(email.Parts, MessagePart{
+ ContentType: "text/html",
+ Content: "Test
Link",
+ IsHTML: true,
+ })
+
+ return email
+}
diff --git a/pkg/analyzer/rspamd-symbols-README.md b/pkg/analyzer/rspamd-symbols-README.md
deleted file mode 100644
index 882eab2..0000000
--- a/pkg/analyzer/rspamd-symbols-README.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# rspamd-symbols.json
-
-This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
-
-## How to update
-
-Fetch the latest symbols from a running rspamd instance:
-
-```sh
-curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
-```
-
-Or with docker:
-
-```sh
-docker run --rm --name rspamd --pull always rspamd/rspamd
-docker exec -u 0 rspamd apt install -y curl
-docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
-```
-
-Then rebuild the project.
diff --git a/pkg/analyzer/rspamd-symbols.json b/pkg/analyzer/rspamd-symbols.json
deleted file mode 100644
index 5538985..0000000
--- a/pkg/analyzer/rspamd-symbols.json
+++ /dev/null
@@ -1,6646 +0,0 @@
-[
- {
- "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
deleted file mode 100644
index a0955ef..0000000
--- a/pkg/analyzer/rspamd.go
+++ /dev/null
@@ -1,174 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2026 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index e50a452..0000000
--- a/pkg/analyzer/rspamd_symbols.go
+++ /dev/null
@@ -1,105 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2026 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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
deleted file mode 100644
index 9804f1d..0000000
--- a/pkg/analyzer/rspamd_test.go
+++ /dev/null
@@ -1,414 +0,0 @@
-// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2026 happyDomain
-// Authors: Pierre-Olivier Mercier, et al.
-//
-// This program is offered under a commercial and under the AGPL license.
-// For commercial licensing, contact us at .
-//
-// 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 0baeab7..03ab870 100644
--- a/pkg/analyzer/scoring.go
+++ b/pkg/analyzer/scoring.go
@@ -22,80 +22,524 @@
package analyzer
import (
- "git.happydns.org/happyDeliver/internal/model"
+ "fmt"
+ "strings"
+ "time"
+
+ "git.happydns.org/happyDeliver/internal/api"
)
-// ScoreToGrade converts a percentage score (0-100) to a letter grade
-func ScoreToGrade(score int) string {
+// DeliverabilityScorer aggregates all analysis results and computes overall score
+type DeliverabilityScorer struct{}
+
+// NewDeliverabilityScorer creates a new deliverability scorer
+func NewDeliverabilityScorer() *DeliverabilityScorer {
+ return &DeliverabilityScorer{}
+}
+
+// ScoringResult represents the complete scoring result
+type ScoringResult struct {
+ OverallScore float32
+ Rating string // Excellent, Good, Fair, Poor, Critical
+ AuthScore float32
+ SpamScore float32
+ BlacklistScore float32
+ ContentScore float32
+ HeaderScore float32
+ Recommendations []string
+ CategoryBreakdown map[string]CategoryScore
+}
+
+// CategoryScore represents score breakdown for a category
+type CategoryScore struct {
+ Score float32
+ MaxScore float32
+ Percentage float32
+ Status string // Pass, Warn, Fail
+}
+
+// CalculateScore computes the overall deliverability score from all analyzers
+func (s *DeliverabilityScorer) CalculateScore(
+ authResults *api.AuthenticationResults,
+ spamResult *SpamAssassinResult,
+ rblResults *RBLResults,
+ contentResults *ContentResults,
+ email *EmailMessage,
+) *ScoringResult {
+ result := &ScoringResult{
+ CategoryBreakdown: make(map[string]CategoryScore),
+ Recommendations: []string{},
+ }
+
+ // Calculate individual scores
+ result.AuthScore = s.GetAuthenticationScore(authResults)
+
+ spamAnalyzer := NewSpamAssassinAnalyzer()
+ result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
+
+ rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
+ result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
+
+ contentAnalyzer := NewContentAnalyzer(10 * time.Second)
+ result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
+
+ // Calculate header quality score
+ result.HeaderScore = s.calculateHeaderScore(email)
+
+ // Calculate overall score (out of 10)
+ result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
+
+ // Ensure score is within bounds
+ if result.OverallScore > 10.0 {
+ result.OverallScore = 10.0
+ }
+ if result.OverallScore < 0.0 {
+ result.OverallScore = 0.0
+ }
+
+ // Determine rating
+ result.Rating = s.determineRating(result.OverallScore)
+
+ // Build category breakdown
+ result.CategoryBreakdown["Authentication"] = CategoryScore{
+ Score: result.AuthScore,
+ MaxScore: 3.0,
+ Percentage: (result.AuthScore / 3.0) * 100,
+ Status: s.getCategoryStatus(result.AuthScore, 3.0),
+ }
+
+ result.CategoryBreakdown["Spam Filters"] = CategoryScore{
+ Score: result.SpamScore,
+ MaxScore: 2.0,
+ Percentage: (result.SpamScore / 2.0) * 100,
+ Status: s.getCategoryStatus(result.SpamScore, 2.0),
+ }
+
+ result.CategoryBreakdown["Blacklists"] = CategoryScore{
+ Score: result.BlacklistScore,
+ MaxScore: 2.0,
+ Percentage: (result.BlacklistScore / 2.0) * 100,
+ Status: s.getCategoryStatus(result.BlacklistScore, 2.0),
+ }
+
+ result.CategoryBreakdown["Content Quality"] = CategoryScore{
+ Score: result.ContentScore,
+ MaxScore: 2.0,
+ Percentage: (result.ContentScore / 2.0) * 100,
+ Status: s.getCategoryStatus(result.ContentScore, 2.0),
+ }
+
+ result.CategoryBreakdown["Email Structure"] = CategoryScore{
+ Score: result.HeaderScore,
+ MaxScore: 1.0,
+ Percentage: (result.HeaderScore / 1.0) * 100,
+ Status: s.getCategoryStatus(result.HeaderScore, 1.0),
+ }
+
+ // Generate recommendations
+ result.Recommendations = s.generateRecommendations(result)
+
+ return result
+}
+
+// calculateHeaderScore evaluates email structural quality (0-1 point)
+func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
+ if email == nil {
+ return 0.0
+ }
+
+ score := float32(0.0)
+ requiredHeaders := 0
+ presentHeaders := 0
+
+ // Check required headers (RFC 5322)
+ headers := map[string]bool{
+ "From": false,
+ "Date": false,
+ "Message-ID": false,
+ }
+
+ for header := range headers {
+ requiredHeaders++
+ if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
+ headers[header] = true
+ presentHeaders++
+ }
+ }
+
+ // Score based on required headers (0.4 points)
+ if presentHeaders == requiredHeaders {
+ score += 0.4
+ } else {
+ score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders))
+ }
+
+ // Check recommended headers (0.3 points)
+ recommendedHeaders := []string{"Subject", "To", "Reply-To"}
+ recommendedPresent := 0
+ for _, header := range recommendedHeaders {
+ if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
+ recommendedPresent++
+ }
+ }
+ score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
+
+ // Check for proper MIME structure (0.2 points)
+ if len(email.Parts) > 0 {
+ score += 0.2
+ }
+
+ // Check Message-ID format (0.1 points)
+ if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
+ if s.isValidMessageID(messageID) {
+ score += 0.1
+ }
+ }
+
+ // Ensure score doesn't exceed 1.0
+ if score > 1.0 {
+ score = 1.0
+ }
+
+ return score
+}
+
+// isValidMessageID checks if a Message-ID has proper format
+func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
+ // Basic check: should be in format <...@...>
+ if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
+ return false
+ }
+
+ // Remove angle brackets
+ messageID = strings.TrimPrefix(messageID, "<")
+ messageID = strings.TrimSuffix(messageID, ">")
+
+ // Should contain @ symbol
+ if !strings.Contains(messageID, "@") {
+ return false
+ }
+
+ parts := strings.Split(messageID, "@")
+ if len(parts) != 2 {
+ return false
+ }
+
+ // Both parts should be non-empty
+ return len(parts[0]) > 0 && len(parts[1]) > 0
+}
+
+// determineRating determines the rating based on overall score
+func (s *DeliverabilityScorer) determineRating(score float32) string {
switch {
- case score > 100:
- return "A+"
- case score >= 95:
- return "A"
- case score >= 85:
- return "B"
- case score >= 75:
- return "C"
- case score >= 65:
- return "D"
- case score >= 50:
- return "E"
+ case score >= 9.0:
+ return "Excellent"
+ case score >= 7.0:
+ return "Good"
+ case score >= 5.0:
+ return "Fair"
+ case score >= 3.0:
+ return "Poor"
default:
- return "F"
+ return "Critical"
}
}
-// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
-func ScoreToGradeKind(score int) string {
+// getCategoryStatus determines status for a category
+func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
+ percentage := (score / maxScore) * 100
+
switch {
- case score > 100:
- return "A+"
- case score >= 90:
- return "A"
- case score >= 80:
- return "B"
- case score >= 60:
- return "C"
- case score >= 45:
- return "D"
- case score >= 30:
- return "E"
+ case percentage >= 80.0:
+ return "Pass"
+ case percentage >= 50.0:
+ return "Warn"
default:
- return "F"
+ return "Fail"
}
}
-// ScoreToReportGrade converts a percentage score to an model.ReportGrade
-func ScoreToReportGrade(score int) model.ReportGrade {
- return model.ReportGrade(ScoreToGrade(score))
+// generateRecommendations creates actionable recommendations based on scores
+func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string {
+ var recommendations []string
+
+ // Authentication recommendations
+ if result.AuthScore < 2.0 {
+ recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records")
+ } else if result.AuthScore < 3.0 {
+ recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability")
+ }
+
+ // Spam recommendations
+ if result.SpamScore < 1.0 {
+ recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns")
+ } else if result.SpamScore < 1.5 {
+ recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues")
+ }
+
+ // Blacklist recommendations
+ if result.BlacklistScore < 1.0 {
+ recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation")
+ } else if result.BlacklistScore < 2.0 {
+ recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices")
+ }
+
+ // Content recommendations
+ if result.ContentScore < 1.0 {
+ recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure")
+ } else if result.ContentScore < 1.5 {
+ recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency")
+ }
+
+ // Header recommendations
+ if result.HeaderScore < 0.5 {
+ recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)")
+ } else if result.HeaderScore < 1.0 {
+ recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present")
+ }
+
+ // Overall recommendations based on rating
+ if result.Rating == "Excellent" {
+ recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices")
+ } else if result.Rating == "Critical" {
+ recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam")
+ }
+
+ return recommendations
}
-// gradeRank returns a numeric rank for a grade (lower = worse)
-func gradeRank(grade string) int {
- switch grade {
- case "A++":
- return 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
+// GenerateHeaderChecks creates checks for email header quality
+func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
+ var checks []api.Check
+
+ if email == nil {
+ return checks
}
+
+ // Required headers check
+ checks = append(checks, s.generateRequiredHeadersCheck(email))
+
+ // Recommended headers check
+ checks = append(checks, s.generateRecommendedHeadersCheck(email))
+
+ // Message-ID check
+ checks = append(checks, s.generateMessageIDCheck(email))
+
+ // MIME structure check
+ checks = append(checks, s.generateMIMEStructureCheck(email))
+
+ return checks
}
-// MinGrade returns the minimal (worse) grade between the two given grades
-func MinGrade(a, b string) string {
- if gradeRank(a) <= gradeRank(b) {
- return a
+// generateRequiredHeadersCheck checks for required RFC 5322 headers
+func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
+ check := api.Check{
+ Category: api.Headers,
+ Name: "Required Headers",
}
- return b
+
+ requiredHeaders := []string{"From", "Date", "Message-ID"}
+ missing := []string{}
+
+ for _, header := range requiredHeaders {
+ if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
+ missing = append(missing, header)
+ }
+ }
+
+ if len(missing) == 0 {
+ check.Status = api.CheckStatusPass
+ check.Score = 0.4
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Message = "All required headers are present"
+ check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
+ } else {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Severity = api.PtrTo(api.CheckSeverityCritical)
+ check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
+ check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
+ details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
+ check.Details = &details
+ }
+
+ return check
+}
+
+// generateRecommendedHeadersCheck checks for recommended headers
+func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
+ check := api.Check{
+ Category: api.Headers,
+ Name: "Recommended Headers",
+ }
+
+ recommendedHeaders := []string{"Subject", "To", "Reply-To"}
+ missing := []string{}
+
+ for _, header := range recommendedHeaders {
+ if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
+ missing = append(missing, header)
+ }
+ }
+
+ if len(missing) == 0 {
+ check.Status = api.CheckStatusPass
+ check.Score = 0.3
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Message = "All recommended headers are present"
+ check.Advice = api.PtrTo("Your email includes all recommended headers")
+ } else if len(missing) < len(recommendedHeaders) {
+ check.Status = api.CheckStatusWarn
+ check.Score = 0.15
+ check.Severity = api.PtrTo(api.CheckSeverityLow)
+ check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
+ check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
+ details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
+ check.Details = &details
+ } else {
+ check.Status = api.CheckStatusWarn
+ check.Score = 0.0
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ check.Message = "Missing all recommended headers"
+ check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
+ }
+
+ return check
+}
+
+// generateMessageIDCheck validates Message-ID header
+func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
+ check := api.Check{
+ Category: api.Headers,
+ Name: "Message-ID Format",
+ }
+
+ messageID := email.GetHeaderValue("Message-ID")
+
+ if messageID == "" {
+ check.Status = api.CheckStatusFail
+ check.Score = 0.0
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Message = "Message-ID header is missing"
+ check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
+ } else if !s.isValidMessageID(messageID) {
+ check.Status = api.CheckStatusWarn
+ check.Score = 0.05
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ check.Message = "Message-ID format is invalid"
+ check.Advice = api.PtrTo("Use proper Message-ID format: ")
+ 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
}
diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go
new file mode 100644
index 0000000..b28182d
--- /dev/null
+++ b/pkg/analyzer/scoring_test.go
@@ -0,0 +1,762 @@
+// This file is part of the happyDeliver (R) project.
+// Copyright (c) 2025 happyDomain
+// Authors: Pierre-Olivier Mercier, et al.
+//
+// This program is offered under a commercial and under the AGPL license.
+// For commercial licensing, contact us at .
+//
+// 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 96f60dd..00cab21 100644
--- a/pkg/analyzer/spamassassin.go
+++ b/pkg/analyzer/spamassassin.go
@@ -22,13 +22,12 @@
package analyzer
import (
- "math"
+ "fmt"
"regexp"
"strconv"
"strings"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@@ -39,34 +38,44 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
return &SpamAssassinAnalyzer{}
}
+// SpamAssassinResult represents parsed SpamAssassin results
+type SpamAssassinResult struct {
+ IsSpam bool
+ Score float64
+ RequiredScore float64
+ Tests []string
+ TestDetails map[string]SpamTestDetail
+ Version string
+ RawReport string
+}
+
+// SpamTestDetail contains details about a specific spam test
+type SpamTestDetail struct {
+ Name string
+ Score float64
+ Description string
+}
+
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
-func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
+func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
- // 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),
+ result := &SpamAssassinResult{
+ TestDetails: make(map[string]SpamTestDetail),
}
// Parse X-Spam-Status header
- if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
+ if statusHeader, ok := headers["X-Spam-Status"]; ok {
a.parseSpamStatus(statusHeader, result)
}
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
- result.Score = float32(score)
+ result.Score = score
}
}
@@ -77,13 +86,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
- result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
+ result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
- result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
+ result.Version = strings.TrimSpace(versionHeader)
}
return result
@@ -91,7 +100,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// 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 *model.SpamAssassinResult) {
+func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@@ -103,7 +112,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
- result.Score = float32(score)
+ result.Score = score
}
}
@@ -111,19 +120,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
- result.RequiredScore = float32(required)
+ result.RequiredScore = required
}
}
// Extract tests
- testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`)
+ testsRe := regexp.MustCompile(`tests=([^\s]+)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
- result.Tests = &tests
+ result.Tests = tests
}
}
@@ -131,20 +140,17 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
-// Multiline descriptions continue on lines starting with * but without score:
-// * 0.0 TEST_NAME Description line 1
-// * continuation line 2
-// * continuation line 3
-func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
+// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
+// We split on '*' to separate individual tests.
+func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
+ // The report header has been joined by mail.Header.Get(), so we split on '*'
+ // Each segment starting with '*' is either a test line or continuation
segments := strings.Split(report, "*")
// Regex to match test lines: score TEST_NAME Description
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
- var currentTestName string
- var currentDescription strings.Builder
-
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
@@ -154,76 +160,186 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
// 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])
- currentTestName = testName
- currentDescription.Reset()
- currentDescription.WriteString(description)
-
- // Initialize with score
- result.TestDetails[testName] = model.SpamTestDetail{
- Name: testName,
- Score: float32(score),
+ detail := SpamTestDetail{
+ Name: testName,
+ Score: score,
+ Description: description,
}
- } else if currentTestName != "" {
- // This is a continuation line for the current test
- // Add a space before appending to ensure proper word separation
- if currentDescription.Len() > 0 {
- currentDescription.WriteString(" ")
- }
- currentDescription.WriteString(segment)
+ result.TestDetails[testName] = detail
}
}
-
- // Save the last test if exists
- if currentTestName != "" {
- description := strings.TrimSpace(currentDescription.String())
- detail := model.SpamTestDetail{
- Name: currentTestName,
- Score: result.TestDetails[currentTestName].Score,
- Description: &description,
- }
- result.TestDetails[currentTestName] = detail
- }
}
-// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
-func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
+// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points)
+// Scoring:
+// - Score <= 0: 2 points (excellent)
+// - Score < required: 1.5 points (good)
+// - Score slightly above required (< 2x): 1 point (borderline)
+// - Score moderately high (< 3x required): 0.5 points (poor)
+// - Score very high: 0 points (spam)
+func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
if result == nil {
- return 100, "" // No spam scan results, assume good
+ return 0.0
}
- // SpamAssassin score typically ranges from -10 to +20
- // Score < 0 is very likely ham (good)
- // Score 0-5 is threshold range (configurable, usually 5.0)
- // Score > 5 is likely spam
-
score := result.Score
-
- // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
- if score < 0 {
- return 100, "A+" // Perfect score for ham
- } else if score == 0 {
- return 100, "A" // Perfect score for ham
- } else if score >= result.RequiredScore {
- return 0, "F" // Failed spam test
- } else {
- // Linear scale between 0 and required threshold
- percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore))))
- return percentage, ScoreToGrade(percentage - 5)
+ required := result.RequiredScore
+ if required == 0 {
+ required = 5.0 // Default SpamAssassin threshold
}
+
+ // Calculate deliverability score
+ if score <= 0 {
+ return 2.0
+ } else if score < required {
+ // Linear scaling from 1.5 to 2.0 based on how negative/low the score is
+ ratio := score / required
+ return 1.5 + (0.5 * (1.0 - float32(ratio)))
+ } else if score < required*2 {
+ // Slightly above threshold
+ return 1.0
+ } else if score < required*3 {
+ // Moderately high
+ return 0.5
+ }
+
+ // Very high spam score
+ return 0.0
+}
+
+// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
+func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
+ var checks []api.Check
+
+ if result == nil {
+ checks = append(checks, api.Check{
+ Category: api.Spam,
+ Name: "SpamAssassin Analysis",
+ Status: api.CheckStatusWarn,
+ Score: 0.0,
+ Message: "No SpamAssassin headers found",
+ Severity: api.PtrTo(api.CheckSeverityMedium),
+ Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
+ })
+ return checks
+ }
+
+ // Main spam score check
+ mainCheck := a.generateMainSpamCheck(result)
+ checks = append(checks, mainCheck)
+
+ // Add checks for significant spam tests (score > 1.0 or < -1.0)
+ for _, test := range result.Tests {
+ if detail, ok := result.TestDetails[test]; ok {
+ if detail.Score > 1.0 || detail.Score < -1.0 {
+ check := a.generateTestCheck(detail)
+ checks = append(checks, check)
+ }
+ }
+ }
+
+ return checks
+}
+
+// generateMainSpamCheck creates the main spam score check
+func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
+ check := api.Check{
+ Category: api.Spam,
+ Name: "SpamAssassin Score",
+ }
+
+ score := result.Score
+ required := result.RequiredScore
+ if required == 0 {
+ required = 5.0
+ }
+
+ delivScore := a.GetSpamAssassinScore(result)
+ check.Score = delivScore
+
+ // Determine status and message based on score
+ if score <= 0 {
+ check.Status = api.CheckStatusPass
+ check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
+ } else if score < required {
+ check.Status = api.CheckStatusPass
+ check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Advice = api.PtrTo("Your email passes spam filters")
+ } else if score < required*1.5 {
+ check.Status = api.CheckStatusWarn
+ check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
+ } else if score < required*2 {
+ check.Status = api.CheckStatusWarn
+ check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
+ } else {
+ check.Status = api.CheckStatusFail
+ check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
+ check.Severity = api.PtrTo(api.CheckSeverityCritical)
+ check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
+ }
+
+ // Add details
+ if len(result.Tests) > 0 {
+ details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
+ if len(result.Tests) > 5 {
+ details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
+ }
+ check.Details = &details
+ }
+
+ return check
+}
+
+// generateTestCheck creates a check for a specific spam test
+func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
+ check := api.Check{
+ Category: api.Spam,
+ Name: fmt.Sprintf("Spam Test: %s", detail.Name),
+ }
+
+ if detail.Score > 0 {
+ // Negative indicator (increases spam score)
+ if detail.Score > 2.0 {
+ check.Status = api.CheckStatusFail
+ check.Severity = api.PtrTo(api.CheckSeverityHigh)
+ } else {
+ check.Status = api.CheckStatusWarn
+ check.Severity = api.PtrTo(api.CheckSeverityMedium)
+ }
+ check.Score = 0.0
+ check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
+ advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
+ check.Advice = &advice
+ } else {
+ // Positive indicator (decreases spam score)
+ check.Status = api.CheckStatusPass
+ check.Score = 1.0
+ check.Severity = api.PtrTo(api.CheckSeverityInfo)
+ check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
+ advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
+ check.Advice = &advice
+ }
+
+ check.Details = &detail.Description
+
+ return check
+}
+
+// min returns the minimum of two integers
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
}
diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go
index d5e67a9..e7491db 100644
--- a/pkg/analyzer/spamassassin_test.go
+++ b/pkg/analyzer/spamassassin_test.go
@@ -27,8 +27,7 @@ import (
"strings"
"testing"
- "git.happydns.org/happyDeliver/internal/model"
- "git.happydns.org/happyDeliver/internal/utils"
+ "git.happydns.org/happyDeliver/internal/api"
)
func TestParseSpamStatus(t *testing.T) {
@@ -36,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
name string
header string
expectedIsSpam bool
- expectedScore float32
- expectedReq float32
+ expectedScore float64
+ expectedReq float64
expectedTests []string
}{
{
@@ -78,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := &model.SpamAssassinResult{
- TestDetails: make(map[string]model.SpamTestDetail),
+ result := &SpamAssassinResult{
+ TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@@ -92,12 +91,8 @@ func TestParseSpamStatus(t *testing.T) {
if result.RequiredScore != tt.expectedReq {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq)
}
- if len(tt.expectedTests) > 0 {
- if result.Tests == nil {
- t.Errorf("Tests = nil, want %v", tt.expectedTests)
- } else if !stringSliceEqual(*result.Tests, tt.expectedTests) {
- t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests)
- }
+ if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) {
+ t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
}
})
}
@@ -116,27 +111,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
- result := &model.SpamAssassinResult{
- TestDetails: make(map[string]model.SpamTestDetail),
+ result := &SpamAssassinResult{
+ TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
- expectedTests := map[string]model.SpamTestDetail{
+ expectedTests := map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
- Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
+ Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
- Description: utils.PtrTo("From address doesn't match envelope sender"),
+ Description: "From address doesn't match envelope sender",
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
- Description: utils.PtrTo("All mail servers are trusted"),
+ Description: "All mail servers are trusted",
},
}
@@ -149,8 +144,8 @@ func TestParseSpamReport(t *testing.T) {
if detail.Score != expected.Score {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score)
}
- if *detail.Description != *expected.Description {
- t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description)
+ if detail.Description != expected.Description {
+ t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description)
}
}
}
@@ -158,63 +153,56 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
- result *model.SpamAssassinResult
- expectedScore int
- minScore int
- maxScore int
+ result *SpamAssassinResult
+ expectedScore float32
+ minScore float32
+ maxScore float32
}{
{
name: "Nil result",
result: nil,
- expectedScore: 100,
+ expectedScore: 0.0,
},
{
name: "Excellent score (negative)",
- result: &model.SpamAssassinResult{
+ result: &SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
- expectedScore: 100,
+ expectedScore: 2.0,
},
{
name: "Good score (below threshold)",
- result: &model.SpamAssassinResult{
+ result: &SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
- expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60
+ minScore: 1.5,
+ maxScore: 2.0,
},
{
- name: "Score at threshold",
- result: &model.SpamAssassinResult{
- Score: 5.0,
- RequiredScore: 5.0,
- },
- expectedScore: 0, // >= threshold = 0
- },
- {
- name: "Above threshold (spam)",
- result: &model.SpamAssassinResult{
+ name: "Borderline (just above threshold)",
+ result: &SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
- expectedScore: 0, // >= threshold = 0
+ expectedScore: 1.0,
},
{
name: "High spam score",
- result: &model.SpamAssassinResult{
+ result: &SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
- expectedScore: 0, // >= threshold = 0
+ expectedScore: 0.5,
},
{
name: "Very high spam score",
- result: &model.SpamAssassinResult{
+ result: &SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
- expectedScore: 0, // >= threshold = 0
+ expectedScore: 0.0,
},
}
@@ -222,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- score, _ := analyzer.CalculateSpamAssassinScore(tt.result)
+ score := analyzer.GetSpamAssassinScore(tt.result)
if tt.minScore > 0 || tt.maxScore > 0 {
if score < tt.minScore || score > tt.maxScore {
@@ -242,7 +230,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
name string
headers map[string]string
expectedIsSpam bool
- expectedScore float32
+ expectedScore float64
expectedHasDetails bool
}{
{
@@ -308,6 +296,86 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
}
}
+func TestGenerateSpamAssassinChecks(t *testing.T) {
+ tests := []struct {
+ name string
+ result *SpamAssassinResult
+ expectedStatus api.CheckStatus
+ minChecks int
+ }{
+ {
+ name: "Nil result",
+ result: nil,
+ expectedStatus: api.CheckStatusWarn,
+ minChecks: 1,
+ },
+ {
+ name: "Clean email",
+ result: &SpamAssassinResult{
+ IsSpam: false,
+ Score: -0.5,
+ RequiredScore: 5.0,
+ Tests: []string{"ALL_TRUSTED"},
+ TestDetails: map[string]SpamTestDetail{
+ "ALL_TRUSTED": {
+ Name: "ALL_TRUSTED",
+ Score: -1.5,
+ Description: "All mail servers are trusted",
+ },
+ },
+ },
+ expectedStatus: api.CheckStatusPass,
+ minChecks: 2, // Main check + one test detail
+ },
+ {
+ name: "Spam email",
+ result: &SpamAssassinResult{
+ IsSpam: true,
+ Score: 15.0,
+ RequiredScore: 5.0,
+ Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
+ TestDetails: map[string]SpamTestDetail{
+ "BAYES_99": {
+ Name: "BAYES_99",
+ Score: 5.0,
+ Description: "Bayes spam probability is 99 to 100%",
+ },
+ "SPOOFED_SENDER": {
+ Name: "SPOOFED_SENDER",
+ Score: 3.5,
+ Description: "From address doesn't match envelope sender",
+ },
+ },
+ },
+ expectedStatus: api.CheckStatusFail,
+ minChecks: 3, // Main check + two significant tests
+ },
+ }
+
+ analyzer := NewSpamAssassinAnalyzer()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ checks := analyzer.GenerateSpamAssassinChecks(tt.result)
+
+ if len(checks) < tt.minChecks {
+ t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
+ }
+
+ // Check main check (first one)
+ if len(checks) > 0 {
+ mainCheck := checks[0]
+ if mainCheck.Status != tt.expectedStatus {
+ t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
+ }
+ if mainCheck.Category != api.Spam {
+ t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
+ }
+ }
+ })
+ }
+}
+
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
email := &EmailMessage{
@@ -321,6 +389,98 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
}
}
+func TestGenerateMainSpamCheck(t *testing.T) {
+ analyzer := NewSpamAssassinAnalyzer()
+
+ tests := []struct {
+ name string
+ score float64
+ required float64
+ expectedStatus api.CheckStatus
+ }{
+ {"Excellent", -1.0, 5.0, api.CheckStatusPass},
+ {"Good", 2.0, 5.0, api.CheckStatusPass},
+ {"Borderline", 6.0, 5.0, api.CheckStatusWarn},
+ {"High", 8.0, 5.0, api.CheckStatusWarn},
+ {"Very High", 15.0, 5.0, api.CheckStatusFail},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := &SpamAssassinResult{
+ Score: tt.score,
+ RequiredScore: tt.required,
+ }
+
+ check := analyzer.generateMainSpamCheck(result)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Category != api.Spam {
+ t.Errorf("Category = %v, want %v", check.Category, api.Spam)
+ }
+ if !strings.Contains(check.Message, "spam score") {
+ t.Error("Message should contain 'spam score'")
+ }
+ })
+ }
+}
+
+func TestGenerateTestCheck(t *testing.T) {
+ analyzer := NewSpamAssassinAnalyzer()
+
+ tests := []struct {
+ name string
+ detail SpamTestDetail
+ expectedStatus api.CheckStatus
+ }{
+ {
+ name: "High penalty test",
+ detail: SpamTestDetail{
+ Name: "BAYES_99",
+ Score: 5.0,
+ Description: "Bayes spam probability is 99 to 100%",
+ },
+ expectedStatus: api.CheckStatusFail,
+ },
+ {
+ name: "Medium penalty test",
+ detail: SpamTestDetail{
+ Name: "HTML_MESSAGE",
+ Score: 1.5,
+ Description: "Contains HTML",
+ },
+ expectedStatus: api.CheckStatusWarn,
+ },
+ {
+ name: "Positive test",
+ detail: SpamTestDetail{
+ Name: "ALL_TRUSTED",
+ Score: -2.0,
+ Description: "All mail servers are trusted",
+ },
+ expectedStatus: api.CheckStatusPass,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ check := analyzer.generateTestCheck(tt.detail)
+
+ if check.Status != tt.expectedStatus {
+ t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
+ }
+ if check.Category != api.Spam {
+ t.Errorf("Category = %v, want %v", check.Category, api.Spam)
+ }
+ if !strings.Contains(check.Name, tt.detail.Name) {
+ t.Errorf("Check name should contain test name %s", tt.detail.Name)
+ }
+ })
+ }
+}
+
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
@@ -382,26 +542,24 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
}
// Validate score (should be -0.1)
- var expectedScore float32 = -0.1
+ expectedScore := -0.1
if result.Score != expectedScore {
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
}
// Validate required score (should be 5.0)
- var expectedRequired float32 = 5.0
+ expectedRequired := 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate version
- if result.Version == nil {
- t.Errorf("Version should contain 'SpamAssassin', got: nil")
- } else if !strings.Contains(*result.Version, "SpamAssassin") {
- t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version)
+ if !strings.Contains(result.Version, "SpamAssassin") {
+ t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
}
// Validate that tests were extracted
- if len(*result.Tests) == 0 {
+ if len(result.Tests) == 0 {
t.Error("Expected tests to be extracted, got none")
}
@@ -414,7 +572,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
"SPF_HELO_NONE": true,
}
- for _, testName := range *result.Tests {
+ for _, testName := range result.Tests {
if expectedTests[testName] {
t.Logf("Found expected test: %s", testName)
}
@@ -428,11 +586,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
// Log what we actually got for debugging
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
for name, detail := range result.TestDetails {
- t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description)
+ t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
}
// Define expected test details with their scores
- expectedTestDetails := map[string]float32{
+ expectedTestDetails := map[string]float64{
"SPF_PASS": -0.0,
"SPF_HELO_NONE": 0.0,
"DKIM_VALID": -0.1,
@@ -453,15 +611,43 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
if detail.Score != expectedScore {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
}
- if detail.Description == nil || *detail.Description == "" {
+ if detail.Description == "" {
t.Errorf("Test %s should have a description", testName)
}
}
// Test GetSpamAssassinScore
- score, _ := analyzer.CalculateSpamAssassinScore(result)
- if score != 100 {
- t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
+ score := analyzer.GetSpamAssassinScore(result)
+ if score != 2.0 {
+ t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
+ }
+
+ // Test GenerateSpamAssassinChecks
+ checks := analyzer.GenerateSpamAssassinChecks(result)
+ if len(checks) < 1 {
+ t.Fatal("Expected at least 1 check, got none")
+ }
+
+ // Main check should be PASS with excellent score
+ mainCheck := checks[0]
+ if mainCheck.Status != api.CheckStatusPass {
+ t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
+ }
+ if mainCheck.Category != api.Spam {
+ t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
+ }
+ if !strings.Contains(mainCheck.Message, "spam score") {
+ t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
+ }
+ if mainCheck.Score != 2.0 {
+ t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
+ }
+
+ // Log all checks for debugging
+ t.Logf("Generated %d checks:", len(checks))
+ for i, check := range checks {
+ t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
+ i+1, check.Name, check.Message, check.Score, check.Status)
}
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 27e6fc1..3fbf1f1 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -13,65 +13,31 @@
"bootstrap-icons": "^1.13.1"
},
"devDependencies": {
- "@eslint/compat": "^2.0.0",
- "@eslint/js": "^10.0.0",
- "@hey-api/openapi-ts": "0.86.10",
+ "@eslint/compat": "^1.4.0",
+ "@eslint/js": "^9.36.0",
+ "@hey-api/openapi-ts": "0.85.2",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
- "@types/node": "^24.0.0",
- "eslint": "^10.0.0",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/node": "^22",
+ "eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
- "globals": "^17.0.0",
+ "globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
- "typescript": "^6.0.0",
+ "typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
- "vite": "^8.0.0",
+ "vite": "^7.1.10",
"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.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
+ "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
"cpu": [
"ppc64"
],
@@ -86,9 +52,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
+ "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
"cpu": [
"arm"
],
@@ -103,9 +69,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "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==",
"cpu": [
"arm64"
],
@@ -120,9 +86,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
+ "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
"cpu": [
"x64"
],
@@ -137,9 +103,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
+ "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
"cpu": [
"arm64"
],
@@ -154,9 +120,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -171,9 +137,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
"cpu": [
"arm64"
],
@@ -188,9 +154,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -205,9 +171,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
+ "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
"cpu": [
"arm"
],
@@ -222,9 +188,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
+ "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
"cpu": [
"arm64"
],
@@ -239,9 +205,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
+ "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
"cpu": [
"ia32"
],
@@ -256,9 +222,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "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==",
"cpu": [
"loong64"
],
@@ -273,9 +239,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
+ "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
"cpu": [
"mips64el"
],
@@ -290,9 +256,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
+ "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
"cpu": [
"ppc64"
],
@@ -307,9 +273,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
+ "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
"cpu": [
"riscv64"
],
@@ -324,9 +290,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
+ "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
"cpu": [
"s390x"
],
@@ -341,9 +307,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
+ "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
"cpu": [
"x64"
],
@@ -358,9 +324,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
"cpu": [
"arm64"
],
@@ -375,9 +341,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
+ "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
"cpu": [
"x64"
],
@@ -392,9 +358,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
"cpu": [
"arm64"
],
@@ -409,9 +375,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "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==",
"cpu": [
"x64"
],
@@ -426,9 +392,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
+ "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
"cpu": [
"arm64"
],
@@ -443,9 +409,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "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==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
+ "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
"cpu": [
"x64"
],
@@ -460,9 +426,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "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==",
"cpu": [
"arm64"
],
@@ -477,9 +443,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
+ "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
"cpu": [
"ia32"
],
@@ -494,9 +460,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
+ "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
"cpu": [
"x64"
],
@@ -511,9 +477,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -543,9 +509,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -553,19 +519,19 @@
}
},
"node_modules/@eslint/compat": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz",
- "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz",
+ "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^1.2.1"
+ "@eslint/core": "^0.16.0"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
- "eslint": "^8.40 || 9 || 10"
+ "eslint": "^8.40 || 9"
},
"peerDependenciesMeta": {
"eslint": {
@@ -574,99 +540,128 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.23.5",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
- "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^3.0.5",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
- "minimatch": "^10.2.4"
+ "minimatch": "^3.1.2"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-helpers": {
- "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==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
+ "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^1.2.1"
+ "@eslint/core": "^0.16.0"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
- "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+ "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "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_modules/@eslint/js": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
- "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
+ "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "eslint": "^10.0.0"
- },
- "peerDependenciesMeta": {
- "eslint": {
- "optional": true
- }
}
},
"node_modules/@eslint/object-schema": {
- "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==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
- "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
+ "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==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^1.2.1",
+ "@eslint/core": "^0.16.0",
"levn": "^0.4.1"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hey-api/codegen-core": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz",
- "integrity": "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg==",
+ "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==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=20.19.0"
+ "node": "^18.18.0 || ^20.9.0 || >=22.10.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
@@ -676,9 +671,9 @@
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz",
- "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -695,27 +690,27 @@
}
},
"node_modules/@hey-api/openapi-ts": {
- "version": "0.86.10",
- "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.10.tgz",
- "integrity": "sha512-Ns0dTJp/RUrOMPiJsO4/1E2Sa3VZ1iw2KCdG6PDbd9vLwOXEYW2UmiWMDPOTInLCYB+f8FLMF9T25jtfQe7AZg==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@hey-api/codegen-core": "^0.3.2",
- "@hey-api/json-schema-ref-parser": "1.2.1",
+ "@hey-api/codegen-core": "^0.2.0",
+ "@hey-api/json-schema-ref-parser": "1.2.0",
"ansi-colors": "4.1.3",
- "c12": "3.3.1",
+ "c12": "3.3.0",
"color-support": "1.1.3",
- "commander": "14.0.1",
+ "commander": "13.0.0",
"handlebars": "4.7.8",
- "open": "10.2.0",
+ "open": "10.1.2",
"semver": "7.7.2"
},
"bin": {
- "openapi-ts": "bin/run.js"
+ "openapi-ts": "bin/index.cjs"
},
"engines": {
- "node": ">=20.19.0"
+ "node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
@@ -725,43 +720,29 @@
}
},
"node_modules/@humanfs/core": {
- "version": "0.19.2",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
- "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
- "dependencies": {
- "@humanfs/types": "^0.15.0"
- },
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.8",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
- "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.2",
- "@humanfs/types": "^0.15.0",
+ "@humanfs/core": "^0.19.1",
"@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",
@@ -847,33 +828,42 @@
"dev": true,
"license": "MIT"
},
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
- "@tybys/wasm-util": "^0.10.1"
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
},
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
+ "engines": {
+ "node": ">= 8"
}
},
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
+ "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"
}
},
"node_modules/@polka/url": {
@@ -894,292 +884,10 @@
"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.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==",
+ "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==",
"cpu": [
"arm"
],
@@ -1191,9 +899,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
- "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "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==",
"cpu": [
"arm64"
],
@@ -1205,9 +913,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -1219,9 +927,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
- "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "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==",
"cpu": [
"x64"
],
@@ -1233,9 +941,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "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==",
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
+ "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"cpu": [
"arm64"
],
@@ -1247,9 +955,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1261,16 +969,13 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "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==",
+ "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==",
"cpu": [
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1278,16 +983,13 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "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==",
+ "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==",
"cpu": [
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1295,16 +997,13 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "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==",
+ "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==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1312,16 +1011,13 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "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==",
+ "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==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1329,33 +1025,13 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "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==",
+ "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==",
"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": [
@@ -1363,33 +1039,13 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "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==",
+ "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==",
"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": [
@@ -1397,16 +1053,13 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "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==",
+ "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==",
"cpu": [
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1414,16 +1067,13 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "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==",
+ "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==",
"cpu": [
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1431,16 +1081,13 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "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==",
+ "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==",
"cpu": [
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1448,16 +1095,13 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1465,40 +1109,23 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "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==",
+ "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==",
"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"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
"node_modules/@rollup/rollup-openharmony-arm64": {
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -1510,9 +1137,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -1524,9 +1151,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "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==",
+ "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==",
"cpu": [
"ia32"
],
@@ -1538,9 +1165,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1552,9 +1179,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1566,16 +1193,16 @@
]
},
"node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
- "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "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==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1593,23 +1220,25 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.60.1",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
- "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
+ "version": "2.47.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.2.tgz",
+ "integrity": "sha512-mbUomaJTiADTrq6GT4ZvQ7v1rs0S+wXGMzrjFwjARAKMEF8FpOUmz2uEJ4M9WMJMQOXCMHpKFzJfdjo9O7M22A==",
"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.8.1",
+ "devalue": "^5.3.2",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
- "set-cookie-parser": "^3.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
"sirv": "^3.0.0"
},
"bin": {
@@ -1620,60 +1249,64 @@
},
"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 || ^7.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
- "typescript": "^5.3.3 || ^6.0.0",
- "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
- },
- "typescript": {
- "optional": true
}
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
- "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
+ "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==",
"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.21",
- "obug": "^2.1.0",
- "vitefu": "^1.1.2"
+ "magic-string": "^0.30.17",
+ "vitefu": "^1.1.1"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
- "svelte": "^5.46.4",
- "vite": "^8.0.0-beta.7 || ^8.0.0"
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
}
},
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
- "tslib": "^2.4.0"
+ "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"
}
},
"node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
+ "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
+ "@types/deep-eql": "*"
}
},
"node_modules/@types/cookie": {
@@ -1690,17 +1323,10 @@
"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.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "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"
},
@@ -1712,37 +1338,32 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.12.4",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
- "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
+ "version": "22.18.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz",
+ "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "undici-types": "~7.16.0"
+ "undici-types": "~6.21.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.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
- "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@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",
+ "@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",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.5.0"
+ "ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1752,9 +1373,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.59.3",
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "@typescript-eslint/parser": "^8.46.1",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -1768,17 +1389,18 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
- "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
+ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "@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"
+ "@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1788,20 +1410,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.59.3",
- "@typescript-eslint/types": "^8.59.3",
- "debug": "^4.4.3"
+ "@typescript-eslint/tsconfig-utils": "^8.46.1",
+ "@typescript-eslint/types": "^8.46.1",
+ "debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1811,18 +1433,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
- "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
+ "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.3",
- "@typescript-eslint/visitor-keys": "8.59.3"
+ "@typescript-eslint/types": "8.46.1",
+ "@typescript-eslint/visitor-keys": "8.46.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1833,9 +1455,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
- "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
+ "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==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1846,21 +1468,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
- "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
+ "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@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"
+ "@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1870,14 +1492,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
- "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
+ "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1889,21 +1511,22 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
- "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
+ "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@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"
+ "@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1913,33 +1536,46 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "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==",
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
},
"engines": {
- "node": ">=10"
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
- "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
+ "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@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"
+ "@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1949,19 +1585,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "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==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
+ "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.3",
- "eslint-visitor-keys": "^5.0.0"
+ "@typescript-eslint/types": "8.46.1",
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1988,6 +1624,33 @@
"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",
@@ -2060,11 +1723,12 @@
}
},
"node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2083,9 +1747,9 @@
}
},
"node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2109,6 +1773,22 @@
"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",
@@ -2117,9 +1797,9 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
- "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -2147,14 +1827,11 @@
}
},
"node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
+ "license": "MIT"
},
"node_modules/bootstrap": {
"version": "5.3.8",
@@ -2192,16 +1869,27 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^4.0.2"
+ "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"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": ">=8"
}
},
"node_modules/bundle-name": {
@@ -2221,19 +1909,19 @@
}
},
"node_modules/c12": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.1.tgz",
- "integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz",
+ "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
- "dotenv": "^17.2.3",
+ "dotenv": "^17.2.2",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
- "jiti": "^2.6.1",
+ "jiti": "^2.5.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.0.0",
@@ -2259,6 +1947,16 @@
"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",
@@ -2276,10 +1974,27 @@
"node": ">=18"
}
},
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2322,6 +2037,26 @@
"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",
@@ -2333,19 +2068,26 @@
}
},
"node_modules/commander": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz",
- "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
+ "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=20"
+ "node": ">=18"
}
},
+ "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.4",
- "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
- "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
+ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"dev": true,
"license": "MIT"
},
@@ -2443,9 +2185,9 @@
}
},
"node_modules/default-browser": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
- "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
+ "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2460,9 +2202,9 @@
}
},
"node_modules/default-browser-id": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
- "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "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==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2486,9 +2228,9 @@
}
},
"node_modules/defu": {
- "version": "6.1.7",
- "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
- "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
+ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"dev": true,
"license": "MIT"
},
@@ -2499,27 +2241,17 @@
"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.8.1",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
- "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz",
+ "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/dotenv": {
- "version": "17.4.2",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
- "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -2537,9 +2269,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
+ "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2550,32 +2282,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@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"
+ "@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"
}
},
"node_modules/escape-string-regexp": {
@@ -2592,30 +2324,34 @@
}
},
"node_modules/eslint": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
- "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
+ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.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",
+ "@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",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^9.1.2",
- "eslint-visitor-keys": "^5.0.1",
- "espree": "^11.2.0",
- "esquery": "^1.7.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
@@ -2625,7 +2361,8 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.4",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -2633,7 +2370,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
@@ -2664,9 +2401,9 @@
}
},
"node_modules/eslint-plugin-svelte": {
- "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==",
+ "version": "3.12.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.5.tgz",
+ "integrity": "sha512-4KRG84eAHQfYd9OjZ1K7sCHy0nox+9KwT+s5WCCku3jTim5RV4tVENob274nCwIaApXsYPKAUAZFBxKZ3Wyfjw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2688,7 +2425,7 @@
"url": "https://github.com/sponsors/ota-meshi"
},
"peerDependencies": {
- "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0",
+ "eslint": "^8.57.1 || ^9.0.0",
"svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
@@ -2697,46 +2434,31 @@
}
}
},
- "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": "9.1.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
- "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "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": {
- "@types/esrecurse": "^4.3.1",
- "@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
- "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==",
+ "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": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -2750,27 +2472,27 @@
"license": "MIT"
},
"node_modules/espree": {
- "version": "11.2.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
- "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "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.16.0",
+ "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^5.0.1"
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
- "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2781,21 +2503,13 @@
}
},
"node_modules/esrap": {
- "version": "2.2.9",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
- "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
+ "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"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": {
@@ -2842,9 +2556,9 @@
}
},
"node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -2852,9 +2566,9 @@
}
},
"node_modules/exsolve": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
- "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
+ "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
"dev": true,
"license": "MIT"
},
@@ -2865,6 +2579,36 @@
"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",
@@ -2879,6 +2623,16 @@
"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",
@@ -2910,6 +2664,19 @@
"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",
@@ -2942,9 +2709,9 @@
}
},
"node_modules/flatted": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
- "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -2995,9 +2762,9 @@
}
},
"node_modules/globals": {
- "version": "17.6.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
- "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3007,6 +2774,13 @@
"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",
@@ -3029,6 +2803,16 @@
"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",
@@ -3039,6 +2823,23 @@
"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",
@@ -3107,6 +2908,16 @@
"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",
@@ -3118,9 +2929,9 @@
}
},
"node_modules/is-wsl": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
- "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
+ "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3141,9 +2952,9 @@
"license": "ISC"
},
"node_modules/jiti": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
- "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -3158,9 +2969,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3232,279 +3043,6 @@
"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",
@@ -3539,9 +3077,16 @@
}
},
"node_modules/lodash": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
- "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "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==",
"dev": true,
"license": "MIT"
},
@@ -3553,29 +3098,63 @@
"license": "MIT"
},
"node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "version": "0.30.19",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
+ "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
- "license": "BlueOak-1.0.0",
+ "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",
"dependencies": {
- "brace-expansion": "^5.0.5"
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "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"
},
"funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "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": "*"
}
},
"node_modules/minimist": {
@@ -3616,9 +3195,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@@ -3656,41 +3235,25 @@
"license": "MIT"
},
"node_modules/nypm": {
- "version": "0.6.6",
- "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
- "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
+ "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "citty": "^0.2.2",
+ "citty": "^0.1.6",
+ "consola": "^3.4.2",
"pathe": "^2.0.3",
- "tinyexec": "^1.1.1"
+ "pkg-types": "^2.3.0",
+ "tinyexec": "^1.0.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
- "node": ">=18"
+ "node": "^14.16.0 || >=16.10.0"
}
},
- "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",
@@ -3699,16 +3262,16 @@
"license": "MIT"
},
"node_modules/open": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
- "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz",
+ "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-inside-container": "^1.0.0",
- "wsl-utils": "^0.1.0"
+ "is-wsl": "^3.1.0"
},
"engines": {
"node": ">=18"
@@ -3767,6 +3330,19 @@
"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",
@@ -3805,9 +3381,9 @@
}
},
"node_modules/perfect-debounce": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
- "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz",
+ "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==",
"dev": true,
"license": "MIT"
},
@@ -3819,11 +3395,12 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -3832,21 +3409,21 @@
}
},
"node_modules/pkg-types": {
- "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==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+ "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "confbox": "^0.2.4",
- "exsolve": "^1.0.8",
+ "confbox": "^0.2.2",
+ "exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
- "version": "8.5.14",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
- "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -3863,6 +3440,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3903,9 +3481,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
- "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
@@ -3967,9 +3545,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
- "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3991,11 +3569,12 @@
}
},
"node_modules/prettier": {
- "version": "3.8.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
- "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4007,9 +3586,9 @@
}
},
"node_modules/prettier-plugin-svelte": {
- "version": "3.5.2",
- "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz",
- "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==",
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz",
+ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4027,6 +3606,27 @@
"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",
@@ -4052,44 +3652,31 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/rolldown": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
- "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.130.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
"engines": {
- "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": ">=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_modules/rollup": {
- "version": "4.60.4",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
- "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
+ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4103,41 +3690,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@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",
+ "@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",
"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",
@@ -4151,6 +3728,30 @@
"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",
@@ -4178,9 +3779,9 @@
}
},
"node_modules/set-cookie-parser": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
- "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
+ "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==",
"dev": true,
"license": "MIT"
},
@@ -4263,6 +3864,19 @@
"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",
@@ -4276,25 +3890,37 @@
"url": "https://github.com/sponsors/antfu"
}
},
- "node_modules/svelte": {
- "version": "5.55.7",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
- "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
+ "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==",
+ "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.2.4",
+ "esrap": "^2.1.0",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -4305,9 +3931,9 @@
}
},
"node_modules/svelte-check": {
- "version": "4.4.8",
- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz",
- "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz",
+ "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4329,9 +3955,9 @@
}
},
"node_modules/svelte-eslint-parser": {
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4340,12 +3966,11 @@
"espree": "^10.0.0",
"postcss": "^8.4.49",
"postcss-scss": "^4.0.9",
- "postcss-selector-parser": "^7.0.0",
- "semver": "^7.7.2"
+ "postcss-selector-parser": "^7.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0",
- "pnpm": "10.33.0"
+ "pnpm": "10.18.3"
},
"funding": {
"url": "https://github.com/sponsors/ota-meshi"
@@ -4359,54 +3984,6 @@
}
}
},
- "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",
@@ -4415,24 +3992,21 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
- "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
+ "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
+ "license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.16",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
- "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.4"
+ "picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -4471,6 +4045,19 @@
"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",
@@ -4482,9 +4069,9 @@
}
},
"node_modules/ts-api-utils": {
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4494,14 +4081,6 @@
"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",
@@ -4516,11 +4095,12 @@
}
},
"node_modules/typescript": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
- "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4530,16 +4110,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.59.3",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
- "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
+ "version": "8.46.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
+ "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@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"
+ "@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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4549,8 +4129,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uglify-js": {
@@ -4568,9 +4148,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -4592,114 +4172,14 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "8.0.13",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
- "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+ "version": "7.1.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
+ "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "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",
+ "esbuild": "^0.25.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -4767,10 +4247,33 @@
}
}
},
+ "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.3",
- "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
- "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -4779,7 +4282,7 @@
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -4860,33 +4363,6 @@
}
}
},
- "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",
@@ -4894,81 +4370,6 @@
"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",
@@ -5019,38 +4420,6 @@
"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 90b545e..d0a2578 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,24 +16,24 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
- "@eslint/compat": "^2.0.0",
- "@eslint/js": "^10.0.0",
- "@hey-api/openapi-ts": "0.86.10",
+ "@eslint/compat": "^1.4.0",
+ "@eslint/js": "^9.36.0",
+ "@hey-api/openapi-ts": "0.85.2",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
- "@types/node": "^24.0.0",
- "eslint": "^10.0.0",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/node": "^22",
+ "eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
- "globals": "^17.0.0",
+ "globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
- "typescript": "^6.0.0",
+ "typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
- "vite": "^8.0.0",
+ "vite": "^7.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
diff --git a/web/routes.go b/web/routes.go
index 056115d..754c1b2 100644
--- a/web/routes.go
+++ b/web/routes.go
@@ -23,10 +23,9 @@ package web
import (
"encoding/json"
- "flag"
- "fmt"
"io"
"io/fs"
+ "io/ioutil"
"log"
"net/http"
"net/url"
@@ -42,38 +41,12 @@ 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 {
@@ -93,13 +66,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
- router.GET("/blacklist/", serveOrReverse("/", cfg))
- router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
- router.GET("/domain/", serveOrReverse("/", cfg))
- router.GET("/domain/:domain", serveOrReverse("/", cfg))
- router.GET("/test/", serveOrReverse("/", cfg))
- router.GET("/test/:testid", serveOrReverse("/", cfg))
- router.GET("/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))
@@ -119,7 +85,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
- if forced_url != "" && forced_url != "/" {
+ if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
@@ -148,16 +114,14 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
}
}
- v, _ := io.ReadAll(resp.Body)
+ v, _ := ioutil.ReadAll(resp.Body)
- v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1)
+ v2 := strings.Replace(string(v), "", "{{ .Head }}", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
- "Body": CustomBodyHTML,
- "Head": CustomHeadHTML,
- "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
+ "Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
@@ -175,18 +139,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
- v, _ := io.ReadAll(f)
+ v, _ := ioutil.ReadAll(f)
- v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1)
+ v2 := strings.Replace(string(v), "", "{{ .Head }}", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
- "Body": CustomBodyHTML,
- "Head": CustomHeadHTML,
- "RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
+ "Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
diff --git a/web/src/app.css b/web/src/app.css
index dca80a5..ddae5b6 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -1,9 +1,6 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
- --bs-link-color-rgb: 28, 180, 135;
- --bs-link-hover-color-rgb: 17, 112, 84;
- --bs-tertiary-bg: #e7e8e8;
}
body {
@@ -11,10 +8,6 @@ body {
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
-.bg-tertiary {
- background-color: var(--bs-tertiary-bg);
-}
-
/* Animations */
@keyframes fadeIn {
from {
@@ -81,21 +74,14 @@ body {
/* Custom card styling */
.card {
+ border: none;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
-.card:not(.fade-in .card) {
- border: none;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-}
-
-.fade-in .card:not(.card .card) {
- border: none;
-}
-
-.card:hover:not(.fade-in .card) {
+.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
diff --git a/web/src/app.html b/web/src/app.html
index 9e3bf88..1966776 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -3,38 +3,9 @@