diff --git a/.drone.yml b/.drone.yml index 053beb0..779952f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,7 +9,7 @@ platform: steps: - name: frontend - image: node:22-alpine + image: node:24-alpine commands: - cd web - npm install --network-timeout=100000 @@ -21,7 +21,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -35,7 +35,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -47,7 +47,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin @@ -61,7 +61,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin diff --git a/.gitignore b/.gitignore index 7ece05e..e943630 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/models.gen.go -internal/api/server.gen.go \ No newline at end of file +internal/api/server.gen.go +internal/model/types.gen.go diff --git a/Dockerfile b/Dockerfile index 36d7d33..60a4243 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA # Stage 1: Build the Svelte application -FROM node:22-alpine AS nodebuild +FROM node:24-alpine AS nodebuild WORKDIR /build @@ -31,19 +31,100 @@ COPY --from=nodebuild /build/web/build/ ./web/build/ RUN go generate ./... && \ CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver -# Stage 3: Runtime image with Postfix and all filters +# Stage 3: Prepare perl and spamass-milt +FROM alpine:3 AS pl + +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ + build-base \ + libmilter-dev \ + musl-obstack-dev \ + openssl \ + openssl-dev \ + perl-app-cpanminus \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-cryptx \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@edge \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ + perl-dev \ + spamassassin-client \ + zlib-dev \ + && \ + ln -s /usr/bin/ld /bin/ld + +RUN cpanm --notest Mail::SPF && \ + cpanm --notest Mail::DKIM && \ + cpanm --notest Mail::Milter::Authentication + +RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \ + tar xzf spamass-milter-0.4.0.tar.gz && \ + cd spamass-milter-0.4.0 && \ + ./configure && make install + +# Stage 4: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages -RUN apk add --no-cache \ +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ bash \ ca-certificates \ - opendkim \ - opendkim-utils \ - opendmarc \ + libmilter \ + openssl \ + perl \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-cryptx \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@edge \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ postfix \ postfix-pcre \ - postfix-policyd-spf-perl \ + rspamd \ spamassassin \ spamassassin-client \ supervisor \ @@ -51,9 +132,8 @@ RUN apk add --no-cache \ tzdata \ && rm -rf /var/cache/apk/* -# Get test-only version of postfix-policyd-spf-perl -ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl -RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl +# Copy Mail::Milter::Authentication and its dependancies +COPY --from=pl /usr/local/ /usr/local/ # Create happydeliver user and group RUN addgroup -g 1000 happydeliver && \ @@ -63,12 +143,15 @@ RUN addgroup -g 1000 happydeliver && \ RUN mkdir -p /etc/happydeliver \ /var/lib/happydeliver \ /var/log/happydeliver \ - /var/spool/postfix/opendkim \ - /var/spool/postfix/opendmarc \ - /etc/opendkim/keys \ + /var/cache/authentication_milter \ + /var/lib/authentication_milter \ + /var/spool/postfix/authentication_milter \ + /var/spool/postfix/spamassassin \ + /var/spool/postfix/rspamd \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R opendkim:postfix /var/spool/postfix/opendkim \ - && chown -R opendmarc:postfix /var/spool/postfix/opendmarc + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ + && chown rspamd:mail /var/spool/postfix/rspamd \ + && chmod 750 /var/spool/postfix/rspamd # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -76,9 +159,9 @@ RUN chmod +x /usr/local/bin/happyDeliver # Copy configuration files COPY docker/postfix/ /etc/postfix/ -COPY docker/opendkim/ /etc/opendkim/ -COPY docker/opendmarc/ /etc/opendmarc/ +COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/spamassassin/ /etc/mail/spamassassin/ +COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh @@ -90,11 +173,21 @@ RUN chmod +x /entrypoint.sh EXPOSE 25 8080 # Default configuration -ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net +ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ + HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \ + HAPPYDELIVER_DOMAIN=happydeliver.local \ + HAPPYDELIVER_ADDRESS_PREFIX=test- \ + HAPPYDELIVER_DNS_TIMEOUT=5s \ + HAPPYDELIVER_HTTP_TIMEOUT=10s \ + HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334 # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1 + # Set entrypoint ENTRYPOINT ["/entrypoint.sh"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/README.md b/README.md index b9db23c..4010d7e 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,27 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration -- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers +- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings +![A sample deliverability report](web/static/img/report.webp) + ## Quick Start ### With Docker (Recommended) -The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application. +The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application. #### What's included in the Docker container: - **Postfix MTA**: Receives emails on port 25 -- **OpenDKIM**: DKIM signature verification -- **OpenDMARC**: DMARC policy validation +- **authentication_milter**: Entreprise grade email authentication - **SpamAssassin**: Spam scoring and analysis +- **rspamd**: Second spam filter for cross-validated scoring - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports @@ -36,7 +38,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git cd happydeliver # Edit docker-compose.yml to set your domain -# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables +# Change HAPPYDELIVER_DOMAIN environment variable and hostname # Build and start docker-compose up -d @@ -62,12 +64,86 @@ docker run -d \ -p 25:25 \ -p 8080:8080 \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - -e HOSTNAME=mail.yourdomain.com \ + --hostname mail.yourdomain.com \ -v $(pwd)/data:/var/lib/happydeliver \ -v $(pwd)/logs:/var/log/happydeliver \ happydeliver:latest ``` +#### 3. Configure TLS Certificates (Optional but Recommended) + +To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments. + +##### Using docker-compose + +Add the certificate paths to your `docker-compose.yml`: + +```yaml +environment: + - POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt + - POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key +volumes: + - /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro + - /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro +``` + +##### Using docker run + +```bash +docker run -d \ + --name happydeliver \ + -p 25:25 \ + -p 8080:8080 \ + -e HAPPYDELIVER_DOMAIN=yourdomain.com \ + -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \ + -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \ + --hostname mail.yourdomain.com \ + -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \ + -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \ + -v $(pwd)/data:/var/lib/happydeliver \ + -v $(pwd)/logs:/var/log/happydeliver \ + happydeliver:latest +``` + +**Notes:** +- The certificate file should contain the full certificate chain (certificate + intermediate CAs) +- The private key file must be readable by the postfix user inside the container +- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required) +- If both environment variables are not set, Postfix will run without TLS support + +#### 4. Configure Network and DNS + +##### Open SMTP Port + +Port 25 (SMTP) must be accessible from the internet to receive test emails: + +```bash +# Check if port 25 is listening +netstat -ln | grep :25 + +# Allow port 25 through firewall (example with ufw) +sudo ufw allow 25/tcp + +# For iptables +sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT +``` + +**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support. + +##### Configure DNS Records + +Point your domain to the server's IP address. + +``` +yourdomain.com. IN A 203.0.113.10 +yourdomain.com. IN AAAA 2001:db8::10 +``` + +Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly. + +There is no need for an MX record here since the same host will serve both HTTP and SMTP. + + ### Manual Build #### 1. Build @@ -87,10 +163,27 @@ The server will start on `http://localhost:8080` by default. #### 3. Integrate with your existing e-mail setup -It is expected your setup annotate the email with eg. opendkim, spamassassin, ... +It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -Choose one of the following way to integrate happyDeliver in your existing setup: +#### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). + +If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: + +```bash +./happyDeliver server -receiver-hostname mail.example.com +``` + +Or via environment variable: +```bash +HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server +``` + +**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. #### Postfix LMTP Transport @@ -108,9 +201,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi ``` # Transport map - route test emails to happyDeliver LMTP server - # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 + /^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 ``` 3. Append the created file to `transport_maps` in your `main.cf`: @@ -144,7 +237,7 @@ Response: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "test-550e8400@localhost", + "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost", "status": "pending", "message": "Send your test email to the address above" } @@ -186,24 +279,43 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com **Note:** In production, emails are delivered via LMTP (see integration instructions above). +## Use with happyDomain + +happyDeliver can be driven by [happyDomain](https://happydomain.org) through +the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver) +plugin, so the deliverability of a domain you manage is monitored alongside +its DNS and inbound SMTP posture. + +How it works: + +1. Attach the **Outbound deliverability** checker to the mail service of a zone + in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`; + operators can configure a default instance globally. +2. On each run, the checker calls `POST /api/test` to allocate a fresh + recipient address, prompts the user (or an automated sender) to mail it from + the tested domain, then polls `GET /api/test/{id}` until the report is + ready. +3. The structured report from `GET /api/report/{id}` is translated into + happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam + score, blacklists and headers, plus an overall score threshold + (`min_score`/`warn_score`). +4. Runs repeat on a configurable interval so a regression in deliverability (a + new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...) + surfaces as a domain-level alert in happyDomain. + +See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver) +for build instructions and the full list of run options. + ## Scoring System -The deliverability score is calculated from 0 to 10 based on: +The deliverability score is calculated from A to F based on: -- **Authentication (3 pts)**: SPF, DKIM, DMARC validation -- **Spam (2 pts)**: SpamAssassin score -- **Blacklist (2 pts)**: RBL/DNSBL checks -- **Content (2 pts)**: HTML quality, links, images, unsubscribe -- **Headers (1 pt)**: Required headers, MIME structure - -**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor. - -**Ratings:** -- 9-10: Excellent -- 7-8.9: Good -- 5-6.9: Fair -- 3-4.9: Poor -- 0-2.9: Critical +- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records +- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation +- **Blacklist**: RBL/DNSBL checks +- **Headers**: Required headers, MIME structure, Domain alignment +- **Spam**: SpamAssassin and rspamd scores (combined 50/50) +- **Content**: HTML quality, links, images, unsubscribe ## Funding diff --git a/api/config-models.yaml b/api/config-models.yaml index 9c3425c..aa2fb0e 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,5 +1,9 @@ -package: api +package: model generate: models: true - embedded-spec: false -output: internal/api/models.gen.go + embedded-spec: true +output: internal/model/types.gen.go +output-options: + skip-prune: true +import-mapping: + ./schemas.yaml: "-" diff --git a/api/config-server.yaml b/api/config-server.yaml index 20f8daf..347dbaf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,5 +1,8 @@ package: api generate: gin-server: true + models: true embedded-spec: true output: internal/api/server.gen.go +import-mapping: + ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index 83151de..2dbf304 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -52,7 +52,7 @@ paths: tags: - tests summary: Get test status - description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available. + description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -60,7 +60,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Test status retrieved successfully @@ -75,6 +76,49 @@ paths: schema: $ref: '#/components/schemas/Error' + /tests: + get: + tags: + - tests + summary: List all tests + description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration. + operationId: listTests + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Number of items to skip + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Maximum number of items to return + responses: + '200': + description: List of test summaries + content: + application/json: + schema: + $ref: '#/components/schemas/TestListResponse' + '403': + description: Test listing is disabled + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /report/{id}: get: tags: @@ -88,7 +132,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Report retrieved successfully @@ -116,7 +161,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Raw email retrieved successfully @@ -131,6 +177,107 @@ paths: schema: $ref: '#/components/schemas/Error' + /report/{id}/reanalyze: + post: + tags: + - reports + summary: Reanalyze email and regenerate report + description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes. + operationId: reanalyzeReport + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) + responses: + '200': + description: Report regenerated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '404': + description: Email not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error during reanalysis + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /domain: + post: + tags: + - tests + summary: Test a domain's email configuration + description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately. + operationId: testDomain + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestRequest' + responses: + '200': + description: Domain test completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /blacklist: + post: + tags: + - tests + summary: Check an IP address against DNS blacklists + description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately. + operationId: checkBlacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BlacklistCheckRequest' + responses: + '200': + description: Blacklist check completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BlacklistCheckResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: tags: @@ -149,386 +296,74 @@ paths: components: schemas: Test: - type: object - required: - - id - - email - - status - - created_at - properties: - id: - type: string - format: uuid - description: Unique test identifier - example: "550e8400-e29b-41d4-a716-446655440000" - email: - type: string - format: email - description: Unique test email address - example: "test-550e8400@example.com" - status: - type: string - enum: [pending, analyzed] - description: Current test status (pending = no report yet, analyzed = report available) - example: "analyzed" - created_at: - type: string - format: date-time - description: Test creation timestamp - updated_at: - type: string - format: date-time - description: Last update timestamp - + $ref: './schemas.yaml#/components/schemas/Test' TestResponse: - type: object - required: - - id - - email - - status - properties: - id: - type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" - email: - type: string - format: email - example: "test-550e8400@example.com" - status: - type: string - enum: [pending] - example: "pending" - message: - type: string - example: "Send your test email to the address above" - + $ref: './schemas.yaml#/components/schemas/TestResponse' Report: - type: object - required: - - id - - test_id - - score - - checks - - created_at - properties: - id: - type: string - format: uuid - description: Report identifier - test_id: - type: string - format: uuid - description: Associated test ID - score: - type: number - format: float - minimum: 0 - maximum: 10 - description: Overall deliverability score (0-10) - example: 8.5 - summary: - $ref: '#/components/schemas/ScoreSummary' - checks: - type: array - items: - $ref: '#/components/schemas/Check' - authentication: - $ref: '#/components/schemas/AuthenticationResults' - spamassassin: - $ref: '#/components/schemas/SpamAssassinResult' - dns_records: - type: array - items: - $ref: '#/components/schemas/DNSRecord' - blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - raw_headers: - type: string - description: Raw email headers - created_at: - type: string - format: date-time - + $ref: './schemas.yaml#/components/schemas/Report' ScoreSummary: - type: object - required: - - authentication_score - - spam_score - - blacklist_score - - content_score - - header_score - properties: - authentication_score: - type: number - format: float - minimum: 0 - maximum: 3 - description: SPF/DKIM/DMARC score (max 3 pts) - example: 2.8 - spam_score: - type: number - format: float - minimum: 0 - maximum: 2 - description: SpamAssassin score (max 2 pts) - example: 1.5 - blacklist_score: - type: number - format: float - minimum: 0 - maximum: 2 - description: Blacklist check score (max 2 pts) - example: 2.0 - content_score: - type: number - format: float - minimum: 0 - maximum: 2 - description: Content quality score (max 2 pts) - example: 1.8 - header_score: - type: number - format: float - minimum: 0 - maximum: 1 - description: Header quality score (max 1 pt) - example: 0.9 - - Check: - type: object - required: - - category - - name - - status - - score - - message - properties: - category: - type: string - enum: [authentication, dns, content, blacklist, headers, spam] - description: Check category - example: "authentication" - name: - type: string - description: Check name - example: "DKIM Signature" - status: - type: string - enum: [pass, fail, warn, info, error] - description: Check result status - example: "pass" - score: - type: number - format: float - description: Points contributed to total score - example: 1.0 - message: - type: string - description: Human-readable result message - example: "DKIM signature is valid" - details: - type: string - description: Additional details (may be JSON) - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "info" - advice: - type: string - description: Remediation advice - example: "Your DKIM configuration is correct" - + $ref: './schemas.yaml#/components/schemas/ScoreSummary' + ContentAnalysis: + $ref: './schemas.yaml#/components/schemas/ContentAnalysis' + ContentIssue: + $ref: './schemas.yaml#/components/schemas/ContentIssue' + LinkCheck: + $ref: './schemas.yaml#/components/schemas/LinkCheck' + ImageCheck: + $ref: './schemas.yaml#/components/schemas/ImageCheck' + HeaderAnalysis: + $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' + HeaderCheck: + $ref: './schemas.yaml#/components/schemas/HeaderCheck' + ReceivedHop: + $ref: './schemas.yaml#/components/schemas/ReceivedHop' + DKIMDomainInfo: + $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' + DomainAlignment: + $ref: './schemas.yaml#/components/schemas/DomainAlignment' + HeaderIssue: + $ref: './schemas.yaml#/components/schemas/HeaderIssue' AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - + $ref: './schemas.yaml#/components/schemas/AuthenticationResults' AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none, neutral, softfail, temperror, permerror] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - + $ref: './schemas.yaml#/components/schemas/AuthResult' ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - + $ref: './schemas.yaml#/components/schemas/ARCResult' + IPRevResult: + $ref: './schemas.yaml#/components/schemas/IPRevResult' SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - properties: - score: - type: number - format: float - description: SpamAssassin spam score - example: 2.3 - required_score: - type: number - format: float - description: Threshold for spam classification - example: 5.0 - is_spam: - type: boolean - description: Whether message is classified as spam - example: false - tests: - type: array - items: - type: string - description: List of triggered SpamAssassin tests - example: ["BAYES_00", "DKIM_SIGNED"] - report: - type: string - description: Full SpamAssassin report - - DNSRecord: - type: object - required: - - domain - - record_type - - status - properties: - domain: - type: string - description: Domain name - example: "example.com" - record_type: - type: string - enum: [MX, SPF, DKIM, DMARC, BIMI] - description: DNS record type - example: "SPF" - status: - type: string - enum: [found, missing, invalid] - description: Record status - example: "found" - value: - type: string - description: Record value - example: "v=spf1 include:_spf.example.com ~all" - + $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' + SpamTestDetail: + $ref: './schemas.yaml#/components/schemas/SpamTestDetail' + RspamdResult: + $ref: './schemas.yaml#/components/schemas/RspamdResult' + DNSResults: + $ref: './schemas.yaml#/components/schemas/DNSResults' + MXRecord: + $ref: './schemas.yaml#/components/schemas/MXRecord' + SPFRecord: + $ref: './schemas.yaml#/components/schemas/SPFRecord' + DKIMRecord: + $ref: './schemas.yaml#/components/schemas/DKIMRecord' + DMARCRecord: + $ref: './schemas.yaml#/components/schemas/DMARCRecord' + BIMIRecord: + $ref: './schemas.yaml#/components/schemas/BIMIRecord' BlacklistCheck: - type: object - required: - - ip - - rbl - - listed - properties: - ip: - type: string - description: IP address checked - example: "192.0.2.1" - rbl: - type: string - description: RBL/DNSBL name - example: "zen.spamhaus.org" - listed: - type: boolean - description: Whether IP is listed - example: false - response: - type: string - description: RBL response code or message - example: "127.0.0.2" - + $ref: './schemas.yaml#/components/schemas/BlacklistCheck' Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - + $ref: './schemas.yaml#/components/schemas/Status' Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details + $ref: './schemas.yaml#/components/schemas/Error' + DomainTestRequest: + $ref: './schemas.yaml#/components/schemas/DomainTestRequest' + DomainTestResponse: + $ref: './schemas.yaml#/components/schemas/DomainTestResponse' + BlacklistCheckRequest: + $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' + BlacklistCheckResponse: + $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' + TestSummary: + $ref: './schemas.yaml#/components/schemas/TestSummary' + TestListResponse: + $ref: './schemas.yaml#/components/schemas/TestListResponse' diff --git a/api/schemas.yaml b/api/schemas.yaml new file mode 100644 index 0000000..53aa297 --- /dev/null +++ b/api/schemas.yaml @@ -0,0 +1,1221 @@ +openapi: 3.0.3 +info: + title: happyDeliver Schemas + description: Shared schema definitions for happyDeliver + version: 0.1.0 + +paths: {} + +components: + schemas: + Test: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + description: Unique test email address + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) + example: "analyzed" + + TestResponse: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending] + example: "pending" + message: + type: string + example: "Send your test email to the address above" + + Report: + type: object + required: + - id + - test_id + - score + - grade + - created_at + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + summary: + $ref: '#/components/schemas/ScoreSummary' + authentication: + $ref: '#/components/schemas/AuthenticationResults' + spamassassin: + $ref: '#/components/schemas/SpamAssassinResult' + rspamd: + $ref: '#/components/schemas/RspamdResult' + dns_results: + $ref: '#/components/schemas/DNSResults' + blacklists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + listed: false + - rbl: "bl.spamcop.net" + listed: false + whitelists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their DNS whitelist check results (informational only) + example: + "192.0.2.1": + - rbl: "list.dnswl.org" + listed: false + - rbl: "swl.spamhaus.org" + listed: false + content_analysis: + $ref: '#/components/schemas/ContentAnalysis' + header_analysis: + $ref: '#/components/schemas/HeaderAnalysis' + raw_headers: + type: string + description: Raw email headers + created_at: + type: string + format: date-time + + ScoreSummary: + type: object + required: + - dns_score + - dns_grade + - authentication_score + - authentication_grade + - spam_score + - spam_grade + - blacklist_score + - blacklist_grade + - header_score + - header_grade + - content_score + - content_grade + properties: + dns_score: + type: integer + minimum: 0 + maximum: 100 + description: DNS records score (in percentage) + example: 42 + dns_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + authentication_score: + type: integer + minimum: 0 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 + authentication_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + spam_score: + type: integer + minimum: 0 + maximum: 100 + description: Spam filter score (SpamAssassin + rspamd combined, in percentage) + example: 15 + spam_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + blacklist_score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 + blacklist_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + header_score: + type: integer + minimum: 0 + maximum: 100 + description: Header quality score (in percentage) + example: 9 + header_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + content_score: + type: integer + minimum: 0 + maximum: 100 + description: Content quality score (in percentage) + example: 18 + content_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + + ContentAnalysis: + type: object + properties: + has_html: + type: boolean + description: Whether email contains HTML part + example: true + has_plaintext: + type: boolean + description: Whether email contains plaintext part + example: true + html_issues: + type: array + items: + $ref: '#/components/schemas/ContentIssue' + description: Issues found in HTML content + links: + type: array + items: + $ref: '#/components/schemas/LinkCheck' + description: Analysis of links found in the email + images: + type: array + items: + $ref: '#/components/schemas/ImageCheck' + description: Analysis of images in the email + text_to_image_ratio: + type: number + format: float + description: Ratio of text to images (higher is better) + example: 0.75 + has_unsubscribe_link: + type: boolean + description: Whether email contains an unsubscribe link + example: true + unsubscribe_methods: + type: array + items: + type: string + enum: [link, mailto, list-unsubscribe-header, one-click] + description: Available unsubscribe methods + example: ["link", "list-unsubscribe-header"] + + ContentIssue: + type: object + required: + - type + - severity + - message + properties: + type: + type: string + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] + description: Type of content issue + example: "missing_alt" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "3 images are missing alt attributes" + location: + type: string + description: Where the issue was found + example: "HTML body line 42" + advice: + type: string + description: How to fix this issue + example: "Add descriptive alt text to all images for better accessibility and deliverability" + + LinkCheck: + type: object + required: + - url + - status + properties: + url: + type: string + format: uri + description: The URL found in the email + example: "https://example.com/page" + status: + type: string + enum: [valid, broken, suspicious, redirected, timeout] + description: Link validation status + example: "valid" + http_code: + type: integer + description: HTTP status code received + example: 200 + redirect_chain: + type: array + items: + type: string + description: URLs in the redirect chain, if any + example: ["https://example.com", "https://www.example.com"] + is_shortened: + type: boolean + description: Whether this is a URL shortener + example: false + + ImageCheck: + type: object + required: + - has_alt + properties: + src: + type: string + description: Image source URL or path + example: "https://example.com/logo.png" + has_alt: + type: boolean + description: Whether image has alt attribute + example: true + alt_text: + type: string + description: Alt text content + example: "Company Logo" + is_tracking_pixel: + type: boolean + description: Whether this appears to be a tracking pixel (1x1 image) + example: false + + HeaderAnalysis: + type: object + properties: + has_mime_structure: + type: boolean + description: Whether body has a MIME structure + example: true + headers: + type: object + additionalProperties: + $ref: '#/components/schemas/HeaderCheck' + description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") + example: + from: + present: true + value: "sender@example.com" + valid: true + importance: "required" + date: + present: true + value: "Mon, 1 Jan 2024 12:00:00 +0000" + valid: true + importance: "required" + received_chain: + type: array + items: + $ref: '#/components/schemas/ReceivedHop' + description: Chain of Received headers showing email path + domain_alignment: + $ref: '#/components/schemas/DomainAlignment' + issues: + type: array + items: + $ref: '#/components/schemas/HeaderIssue' + description: Issues found in headers + + HeaderCheck: + type: object + required: + - present + properties: + present: + type: boolean + description: Whether the header is present + example: true + value: + type: string + description: Header value + example: "sender@example.com" + valid: + type: boolean + description: Whether the value is valid/well-formed + example: true + importance: + type: string + enum: [required, recommended, optional, newsletter] + description: How important this header is for deliverability + example: "required" + issues: + type: array + items: + type: string + description: Any issues with this header + example: ["Invalid date format"] + + ReceivedHop: + type: object + properties: + from: + type: string + description: Sending server hostname + example: "mail.example.com" + by: + type: string + description: Receiving server hostname + example: "mx.receiver.com" + with: + type: string + description: Protocol used + example: "ESMTPS" + id: + type: string + description: Message ID at this hop + timestamp: + type: string + format: date-time + description: When this hop occurred + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" + + DKIMDomainInfo: + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + + DomainAlignment: + type: object + properties: + from_domain: + type: string + description: Domain from From header + example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" + return_path_domain: + type: string + description: Domain from Return-Path header + example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" + dkim_domains: + type: array + items: + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains + aligned: + type: boolean + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) + example: true + issues: + type: array + items: + type: string + description: Alignment issues + example: ["Return-Path domain does not match From domain"] + + HeaderIssue: + type: object + required: + - header + - severity + - message + properties: + header: + type: string + description: Header name + example: "Date" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "Date header is in the future" + advice: + type: string + description: How to fix this issue + example: "Ensure your mail server clock is synchronized with NTP" + + AuthenticationResults: + type: object + properties: + spf: + $ref: '#/components/schemas/AuthResult' + dkim: + type: array + items: + $ref: '#/components/schemas/AuthResult' + dmarc: + $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' + iprev: + $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) + x_aligned_from: + $ref: '#/components/schemas/AuthResult' + description: X-Aligned-From authentication result (checks address alignment) + + AuthResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] + description: Authentication result + example: "pass" + domain: + type: string + description: Domain being authenticated + example: "example.com" + selector: + type: string + description: DKIM selector (for DKIM only) + example: "default" + details: + type: string + description: Additional details about the result + + ARCResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none] + description: Overall ARC chain validation result + example: "pass" + chain_valid: + type: boolean + description: Whether the ARC chain signatures are valid + example: true + chain_length: + type: integer + description: Number of ARC sets in the chain + example: 2 + details: + type: string + description: Additional details about ARC validation + example: "ARC chain valid with 2 intermediaries" + + IPRevResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, temperror, permerror] + description: IP reverse DNS lookup result + example: "pass" + ip: + type: string + description: IP address that was checked + example: "195.110.101.58" + hostname: + type: string + description: Hostname from reverse DNS lookup (PTR record) + example: "authsmtp74.register.it" + details: + type: string + description: Additional details about the IP reverse lookup + example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + + SpamAssassinResult: + type: object + required: + - score + - required_score + - is_spam + - test_details + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: SpamAssassin deliverability score (0-100, higher is better) + example: 80 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for SpamAssassin deliverability score + example: "B" + version: + type: string + description: SpamAssassin version + example: "SpamAssassin 4.0.1" + score: + type: number + format: float + description: SpamAssassin spam score + example: 2.3 + required_score: + type: number + format: float + description: Threshold for spam classification + example: 5.0 + is_spam: + type: boolean + description: Whether message is classified as spam + example: false + tests: + type: array + items: + type: string + description: List of triggered SpamAssassin tests + example: ["BAYES_00", "DKIM_SIGNED"] + test_details: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of test names to their detailed results + example: + BAYES_00: + name: "BAYES_00" + score: -1.9 + description: "Bayes spam probability is 0 to 1%" + DKIM_SIGNED: + name: "DKIM_SIGNED" + score: 0.1 + description: "Message has a DKIM or DK signature, not necessarily valid" + report: + type: string + description: Full SpamAssassin report + + SpamTestDetail: + type: object + required: + - name + - score + properties: + name: + type: string + description: Test name + example: "BAYES_00" + score: + type: number + format: float + description: Score contribution of this test + example: -1.9 + params: + type: string + description: Symbol parameters or options + example: "0.02" + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + + RspamdResult: + type: object + required: + - score + - threshold + - is_spam + - symbols + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: rspamd deliverability score (0-100, higher is better) + example: 85 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for rspamd deliverability score + example: "A" + score: + type: number + format: float + description: rspamd spam score + example: -3.91 + threshold: + type: number + format: float + description: Score threshold for spam classification + example: 15.0 + action: + type: string + description: rspamd action (no action, add header, rewrite subject, soft reject, reject) + example: "no action" + is_spam: + type: boolean + description: Whether message is classified as spam (action is reject or soft reject) + example: false + server: + type: string + description: rspamd server that processed the message + example: "rspamd.example.com" + symbols: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of triggered rspamd symbols to their details + example: + BAYES_HAM: + name: "BAYES_HAM" + score: -1.9 + params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) + + + DNSResults: + type: object + required: + - from_domain + properties: + from_domain: + type: string + description: From Domain name + example: "example.com" + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain + spf_records: + type: array + items: + $ref: '#/components/schemas/SPFRecord' + description: SPF records found (includes resolved include directives) + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] + errors: + type: array + items: + type: string + description: DNS lookup errors + + MXRecord: + type: object + required: + - host + - priority + - valid + properties: + host: + type: string + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "Failed to lookup MX records" + + SPFRecord: + type: object + required: + - valid + properties: + domain: + type: string + description: Domain this SPF record belongs to + example: "example.com" + record: + type: string + description: SPF record content + example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + all_qualifier: + type: string + enum: ["+", "-", "~", "?"] + description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" + example: "~" + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + + DKIMRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + key_type: + type: string + description: "Key type from k= tag (e.g. rsa, ed25519); defaults to rsa if absent" + example: "rsa" + hash_algorithms: + type: array + items: + type: string + description: "Acceptable hash algorithms from h= tag; empty means all accepted (RFC 6376 default: sha256)" + example: ["sha256"] + signing_algorithm: + type: string + description: "Algorithm used in DKIM-Signature a= tag (e.g. rsa-sha256, ed25519-sha256)" + example: "rsa-sha256" + key_size: + type: integer + description: "Public key size in bits (RSA: 1024/2048/4096; Ed25519: always 256)" + example: 2048 + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + domain: + type: string + description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used) + example: "example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + nonexistent_subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis) + example: "reject" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead." + example: 100 + test_mode: + type: boolean + description: "DMARCbis t= tag: when true (t=y), receivers downgrade effective policy one level (reject→quarantine, quarantine→none). Replaces the deprecated pct= tag for testing." + example: false + psd: + type: string + enum: [y, n, u] + description: "DMARCbis psd= tag: y=this is a Public Suffix Domain, n=this is an Organizational Domain boundary, u=unknown (default, use DNS Tree Walk to determine)" + example: "u" + deprecated_pct: + type: boolean + description: "Whether the deprecated pct= tag was found in the record (pct is removed in DMARCbis; migrate to t=y for testing mode)" + example: false + deprecated_rf: + type: boolean + description: "Whether the deprecated rf= tag was found in the record (rf is removed in DMARCbis; failure report formats are now defined separately)" + example: false + deprecated_ri: + type: boolean + description: "Whether the deprecated ri= tag was found in the record (ri is removed in DMARCbis; aggregate reporting interval is now fixed at ≥24 hours)" + example: false + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" + + BlacklistCheck: + type: object + required: + - rbl + - listed + properties: + rbl: + type: string + description: RBL/DNSBL name + example: "zen.spamhaus.org" + listed: + type: boolean + description: Whether IP is listed + example: false + response: + type: string + description: RBL response code or message + example: "127.0.0.2" + error: + type: string + description: RBL error if any + + Status: + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + + Error: + type: object + required: + - error + - message + properties: + error: + type: string + description: Error code + example: "not_found" + message: + type: string + description: Human-readable error message + example: "Test not found" + details: + type: string + description: Additional error details + + DomainTestRequest: + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + + DomainTestResponse: + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' + + BlacklistCheckRequest: + type: object + required: + - ip + properties: + ip: + type: string + description: IPv4 or IPv6 address to check against blacklists + example: "192.0.2.1" + pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' + + BlacklistCheckResponse: + type: object + required: + - ip + - blacklists + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of blacklist check results + listed_count: + type: integer + description: Number of blacklists that have this IP listed + example: 0 + score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist score (0-100, higher is better) + example: 100 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A+" + whitelists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of DNS whitelist check results (informational only) + + TestSummary: + type: object + required: + - test_id + - score + - grade + - created_at + properties: + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Test identifier (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score (0-100) + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade + from_domain: + type: string + description: Sender domain extracted from the report + created_at: + type: string + format: date-time + + TestListResponse: + type: object + required: + - tests + - total + - offset + - limit + properties: + tests: + type: array + items: + $ref: '#/components/schemas/TestSummary' + total: + type: integer + description: Total number of tests + offset: + type: integer + description: Current offset + limit: + type: integer + description: Current limit diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 01d99f1..3caf4d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -29,13 +29,12 @@ import ( "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/version" ) -const version = "0.1.0-dev" - func main() { - fmt.Println("happyDeliver - Email Deliverability Testing Platform") - fmt.Printf("Version: %s\n", version) + fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform") + fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version) cfg, err := config.ConsolidateConfig() if err != nil { @@ -53,8 +52,20 @@ func main() { if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { log.Fatalf("Analyzer error: %v", err) } + case "backup": + if err := app.RunBackup(cfg); err != nil { + log.Fatalf("Backup error: %v", err) + } + case "restore": + inputFile := "" + if len(flag.Args()) >= 2 { + inputFile = flag.Args()[1] + } + if err := app.RunRestore(cfg, inputFile); err != nil { + log.Fatalf("Restore error: %v", err) + } case "version": - fmt.Println(version) + fmt.Println(version.Version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -64,9 +75,11 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") + fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/docker-compose.yml b/docker-compose.yml index 87521ef..dc34330 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,30 @@ services: + unbound: + image: alpinelinux/unbound + restart: unless-stopped + + configs: + - source: unbound_conf + target: /etc/unbound/unbound.conf + uid: "100" + gid: "101" + + networks: + default: + ipv4_address: 172.28.0.53 + happydeliver: build: context: . dockerfile: Dockerfile - image: happydeliver:latest + image: happydomain/happydeliver:latest container_name: happydeliver + # Set a hostname hostname: mail.happydeliver.local environment: - # Set your domain and hostname - DOMAIN: happydeliver.local - HOSTNAME: mail.happydeliver.local + # Set your domain + HAPPYDELIVER_DOMAIN: happydeliver.local ports: # SMTP port @@ -24,15 +38,41 @@ services: # Log files - ./logs:/var/log/happydeliver + dns: + - 172.28.0.53 restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s +configs: + unbound_conf: + content: | + server: + verbosity: 1 + interface: 0.0.0.0 + port: 53 + do-ip4: yes + do-ip6: no + do-udp: yes + do-tcp: yes + + access-control: 127.0.0.0/8 allow + access-control: 172.28.0.0/24 allow + + # Short cache for a testing resolver + cache-max-ttl: 60 + + # Buffers: let the system decide + so-sndbuf: 0 + so-rcvbuf: 0 + + # Trust anchor (static, ships with the image) + trust-anchor-file: "/etc/unbound/root.key" volumes: data: logs: + +networks: + default: + ipam: + config: + - subnet: 172.28.0.0/24 diff --git a/docker/README.md b/docker/README.md index 45cce6b..2199eeb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -109,12 +109,37 @@ Default configuration for the Docker environment: The container accepts these environment variables: -- `DOMAIN`: Email domain for test addresses (default: happydeliver.local) -- `HOSTNAME`: Container hostname (default: mail.happydeliver.local) +- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) +- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP + +### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). + +In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. + +**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: -Example: ```bash -docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ... +docker run -d \ + -e HAPPYDELIVER_DOMAIN=example.com \ + -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ + ... +``` + +To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. + +Example (all-in-one, no override needed): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... +``` + +Example (external MTA integration): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... ``` ## Volumes diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json new file mode 100644 index 0000000..5db3bbc --- /dev/null +++ b/docker/authentication_milter/authentication_milter.json @@ -0,0 +1,75 @@ +{ + "logtoerr" : "1", + "error_log" : "", + "connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock", + "umask" : "0007", + "runas" : "mail", + "rungroup" : "mail", + "authserv_id" : "__HOSTNAME__", + + "connect_timeout" : 30, + "command_timeout" : 30, + "content_timeout" : 300, + "dns_timeout" : 10, + "dns_retry" : 2, + + "handlers" : { + + "Sanitize" : { + "hosts_to_remove" : [ + "__HOSTNAME__" + ], + "extra_auth_results_types" : [ + "X-Spam-Status", + "X-Spam-Report", + "X-Spam-Level", + "X-Spam-Checker-Version" + ] + }, + + "SPF" : { + "hide_none" : 0 + }, + + "DKIM" : { + "hide_none" : 0, + }, + + "XGoogleDKIM" : { + "hide_none" : 1, + }, + + "ARC" : { + "hide_none" : 0, + }, + + "DMARC" : { + "hide_none" : 0, + "detect_list_id" : "1" + }, + + "BIMI" : {}, + + "PTR" : {}, + + "SenderID" : { + "hide_none" : 1 + }, + + "IPRev" : {}, + + "Auth" : {}, + + "AlignedFrom" : {}, + + "LocalIP" : {}, + + "TrustedIP" : { + "trusted_ip_list" : [] + }, + + "!AddID" : {}, + + "ReturnOK" : {} + } +} diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini new file mode 100644 index 0000000..8097ac6 --- /dev/null +++ b/docker/authentication_milter/mail-dmarc.ini @@ -0,0 +1,58 @@ +; This is YOU. DMARC reports include information about the reports. Enter it here. +[organization] +domain = example.com +org_name = My Company Limited +email = admin@example.com +extra_contact_info = http://example.com + +; aggregate DMARC reports need to be stored somewhere. Any database +; with a DBI module (MySQL, SQLite, DBD, etc.) should work. +; SQLite and MySQL are tested. +; Default is sqlite. +[report_store] +backend = SQL +;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite +dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306 +user = authmilterusername +pass = authmiltpassword + +; backend can be perl or libopendmarc +[dmarc] +backend = perl + +[dns] +timeout = 5 +public_suffix_list = share/public_suffix_list + +[smtp] +; hostname is the external FQDN of this MTA +hostname = mx1.example.com +cc = dmarc.copy@example.com + +; list IP addresses to whitelist (bypass DMARC reject/quarantine) +; see sample whitelist in share/dmarc_whitelist +whitelist = /path/to/etc/dmarc_whitelist + +; By default, we attempt to email directly to the report recipient. +; Set these to relay via a SMTP smart host. +smarthost = mx2.example.com +smartuser = dmarccopyusername +smartpass = dmarccopypassword + +[imap] +server = mail.example.com +user = +pass = +; the imap folder where new dmarc messages will be found +folder = dmarc +; the folders to store processed reports (a=aggregate, f=forensic) +f_done = dmarc.forensic +a_done = dmarc.aggregate + +[http] +port = 8080 + +[https] +port = 8443 +ssl_crt = +ssl_key = \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 445602d..ef45b61 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,34 +4,42 @@ set -e echo "Starting happyDeliver container..." # Get environment variables with defaults -HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" +[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" echo "Domain: $HAPPYDELIVER_DOMAIN" -# Create runtime directories -mkdir -p /var/run/opendkim /var/run/opendmarc -chown opendkim:postfix /var/run/opendkim -chown opendmarc:postfix /var/run/opendmarc - # Create socket directories -mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc -chown opendkim:postfix /var/spool/postfix/opendkim -chown opendmarc:postfix /var/spool/postfix/opendmarc -chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc +mkdir -p /var/spool/postfix/authentication_milter +chown mail:mail /var/spool/postfix/authentication_milter +chmod 750 /var/spool/postfix/authentication_milter + +mkdir -p /var/spool/postfix/rspamd +chown rspamd:mail /var/spool/postfix/rspamd +chmod 750 /var/spool/postfix/rspamd # Create log directory -mkdir -p /var/log/happydeliver +mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter chown happydeliver:happydeliver /var/log/happydeliver +chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter # Replace placeholders in Postfix configuration echo "Configuring Postfix..." sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf -# Replace placeholders in OpenDMARC configuration -sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf +# Add certificates to postfix +[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && { + cat <> /etc/postfix/main.cf +smtpd_tls_cert_file = ${POSTFIX_CERT_FILE} +smtpd_tls_key_file = ${POSTFIX_KEY_FILE} +smtpd_tls_security_level = may +EOF +} + +# Replace placeholders in configurations +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json # Initialize Postfix aliases if [ -f /etc/postfix/aliases ]; then diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf deleted file mode 100644 index 8fe2f8c..0000000 --- a/docker/opendkim/opendkim.conf +++ /dev/null @@ -1,39 +0,0 @@ -# OpenDKIM configuration for happyDeliver -# Verifies DKIM signatures on incoming emails - -# Log to syslog -Syslog yes -SyslogSuccess yes -LogWhy yes - -# Run as this user and group -UserID opendkim:mail - -UMask 002 - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendkim/opendkim.sock - -# Process ID file -PidFile /var/run/opendkim/opendkim.pid - -# Operating mode - verify only (not signing) -Mode v - -# Canonicalization methods -Canonicalization relaxed/simple - -# DNS timeout -DNSTimeout 5 - -# Add header for verification results -AlwaysAddARHeader yes - -# Accept unsigned mail -On-NoSignature accept - -# Always add Authentication-Results header -AlwaysAddARHeader yes - -# Maximum verification attempts -MaximumSignaturesToVerify 3 diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf deleted file mode 100644 index 882e11c..0000000 --- a/docker/opendmarc/opendmarc.conf +++ /dev/null @@ -1,41 +0,0 @@ -# OpenDMARC configuration for happyDeliver -# Verifies DMARC policies on incoming emails - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock - -# Process ID file -PidFile /var/run/opendmarc/opendmarc.pid - -# Run as this user and group -UserID opendmarc:mail - -UMask 002 - -# Syslog configuration -Syslog true -SyslogFacility mail - -# Ignore authentication results from other hosts -IgnoreAuthenticatedClients true - -# Accept mail even if DMARC fails (we're analyzing, not filtering) -RejectFailures false - -# Trust Authentication-Results headers from localhost only -TrustedAuthservIDs __HOSTNAME__ - -# Add DMARC results to Authentication-Results header -#AddAuthenticationResults true - -# DNS timeout -DNSTimeout 5 - -# History file (for reporting) -# HistoryFile /var/spool/opendmarc/opendmarc.dat - -# Ignore hosts file -# IgnoreHosts /etc/opendmarc/ignore.hosts - -# Public suffix list -# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 913eb57..5a73fb3 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -10,7 +10,7 @@ inet_interfaces = all inet_protocols = ipv4 # Recipient settings -mydestination = $myhostname, localhost.$mydomain, localhost +mydestination = localhost.$mydomain, localhost mynetworks = 127.0.0.0/8 [::1]/128 # Relay settings - accept mail for our test domain @@ -28,14 +28,13 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking smtpd_recipient_restrictions = permit_mynetworks, - reject_unauth_destination, - check_policy_service unix:private/policy-spf + reject_unauth_destination # Logging debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 92976a4..9c2ac57 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -2,7 +2,6 @@ # SMTP service smtp inet n - n - - smtpd - -o content_filter=spamassassin # Pickup service pickup unix n - n 60 1 pickup @@ -74,10 +73,6 @@ scache unix - - n - 1 scache maildrop unix - n n - - pipe flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} -# SPF policy service -policy-spf unix - n n - 0 spawn - user=nobody argv=/usr/bin/postfix-policyd-spf-perl - # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index 49fdb98..cc1deed 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ # Transport map - route test emails to happyDeliver LMTP server -# Pattern: test-@domain.com -> LMTP on localhost:2525 +# Pattern: test-@domain.com -> LMTP on localhost:2525 -/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 +/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf new file mode 100644 index 0000000..f3ed60c --- /dev/null +++ b/docker/rspamd/local.d/actions.conf @@ -0,0 +1,5 @@ +no_action = 0; +reject = null; +add_header = null; +rewrite_subject = null; +greylist = null; \ No newline at end of file diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf new file mode 100644 index 0000000..378b8a3 --- /dev/null +++ b/docker/rspamd/local.d/milter_headers.conf @@ -0,0 +1,5 @@ +# Add "extended Rspamd headers" +extended_spam_headers = true; + +skip_local = false; +skip_authenticated = false; \ No newline at end of file diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc new file mode 100644 index 0000000..485d0c9 --- /dev/null +++ b/docker/rspamd/local.d/options.inc @@ -0,0 +1,3 @@ +# rspamd options for happyDeliver +# Disable Bayes learning to keep the setup stateless +use_redis = false; diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc new file mode 100644 index 0000000..04c9a1d --- /dev/null +++ b/docker/rspamd/local.d/worker-proxy.inc @@ -0,0 +1,6 @@ +# Enable rspamd milter proxy worker via Unix socket for Postfix integration +bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail"; +upstream "local" { + default = yes; + self_scan = yes; +} diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index c248ef6..ce9a31c 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,3 +48,14 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 + +# Disable Validity network rules +dns_query_restriction deny sa-trusted.bondedsender.org +dns_query_restriction deny sa-accredit.habeas.com +dns_query_restriction deny bl.score.senderscore.com +score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 +score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 +score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 +score RCVD_IN_VALIDITY_CERTIFIED 0 +score RCVD_IN_VALIDITY_RPBL 0 +score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 1a0666e..74f1810 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -22,27 +22,26 @@ autostart=true autorestart=true priority=9 -# OpenDKIM service -[program:opendkim] -command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf +# Authentication Milter service +[program:authentication_milter] +command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid autostart=true autorestart=true priority=10 -stdout_logfile=/var/log/happydeliver/opendkim.log -stderr_logfile=/var/log/happydeliver/opendkim_error.log -user=opendkim +stdout_logfile=/var/log/happydeliver/authentication_milter.log +stderr_logfile=/var/log/happydeliver/authentication_milter.log +user=mail group=mail -# OpenDMARC service -[program:opendmarc] -command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf +# rspamd spam filter +[program:rspamd] +command=/usr/bin/rspamd -f -u rspamd -g mail autostart=true autorestart=true priority=11 -stdout_logfile=/var/log/happydeliver/opendmarc.log -stderr_logfile=/var/log/happydeliver/opendmarc_error.log -user=opendmarc -group=mail +stdout_logfile=/var/log/happydeliver/rspamd.log +stderr_logfile=/var/log/happydeliver/rspamd_error.log +user=root # SpamAssassin daemon [program:spamd] @@ -54,6 +53,18 @@ stdout_logfile=/var/log/happydeliver/spamd.log stderr_logfile=/var/log/happydeliver/spamd_error.log user=root +# SpamAssassin milter +[program:spamass_milter] +command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m +autostart=true +autorestart=true +priority=7 +stdout_logfile=/var/log/happydeliver/spamass-milter.log +stderr_logfile=/var/log/happydeliver/spamass-milter_error.log +user=mail +group=mail +umask=007 + # Postfix service [program:postfix] command=/usr/sbin/postfix start-fg diff --git a/generate.go b/generate.go index d1ee5ab..324c52c 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/go.mod b/go.mod index 7604b07..a975215 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,42 @@ module git.happydns.org/happyDeliver -go 1.24.6 +go 1.25.0 require ( + github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.132.0 - github.com/gin-gonic/gin v1.11.0 + github.com/getkin/kin-openapi v0.138.0 + github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 - github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.46.0 + github.com/oapi-codegen/runtime v1.4.0 + golang.org/x/net v0.54.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.31.0 + gorm.io/gorm v1.31.1 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -40,34 +44,38 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.1 // indirect - github.com/speakeasy-api/jsonpath v0.6.0 // indirect - github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bc46bc0..f4c8d28 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,34 @@ +github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= +github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -24,33 +39,35 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= -github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= +github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= -github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -76,8 +93,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -100,12 +117,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -116,14 +133,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= -github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= -github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= +github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -140,52 +157,69 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= -github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= -github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= -github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -193,13 +227,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -215,21 +249,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -242,8 +276,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -265,5 +299,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= -gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 79d839e..de2d5df 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,21 +31,34 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/version" ) +// EmailAnalyzer defines the interface for email analysis +// This interface breaks the circular dependency with pkg/analyzer +type EmailAnalyzer interface { + AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) + AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) + CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) +} + // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { storage storage.Storage config *config.Config + analyzer EmailAnalyzer startTime time.Time } // NewAPIHandler creates a new API handler -func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { +func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { return &APIHandler{ storage: store, config: cfg, + analyzer: analyzer, startTime: time.Now(), } } @@ -56,79 +69,99 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // Generate a unique test ID (no database record created) testID := uuid.New() - // Generate test email address + // Convert UUID to base32 string for the API response + base32ID := utils.UUIDToBase32(testID) + + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - testID.String(), + base32ID, h.config.Email.Domain, ) // Return response - c.JSON(http.StatusCreated, TestResponse{ - Id: testID, + c.JSON(http.StatusCreated, model.TestResponse{ + Id: base32ID, Email: openapi_types.Email(email), - Status: TestResponseStatusPending, - Message: stringPtr("Send your test email to the address above"), + Status: model.TestResponseStatusPending, + Message: utils.PtrTo("Send your test email to the given address"), }) } // GetTest retrieves test metadata // (GET /test/{id}) -func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { - // Check if a report exists for this test ID - reportExists, err := h.storage.ReportExists(id) +func (h *APIHandler) GetTest(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Check if a report exists for this test ID + reportExists, err := h.storage.ReportExists(testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to check test status", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Determine status based on report existence - var apiStatus TestStatus + var apiStatus model.TestStatus if reportExists { - apiStatus = TestStatusAnalyzed + apiStatus = model.TestStatusAnalyzed } else { - apiStatus = TestStatusPending + apiStatus = model.TestStatusPending } - // Generate test email address + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - id.String(), + id, h.config.Email.Domain, ) - // Return current time for CreatedAt/UpdatedAt since we don't track tests anymore - now := time.Now() - - c.JSON(http.StatusOK, Test{ - Id: id, - Email: openapi_types.Email(email), - Status: apiStatus, - CreatedAt: now, - UpdatedAt: &now, + c.JSON(http.StatusOK, model.Test{ + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, }) } // GetReport retrieves the detailed analysis report // (GET /report/{id}) -func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { - reportJSON, _, err := h.storage.GetReport(id) +func (h *APIHandler) GetReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: utils.PtrTo(err.Error()), + }) + return + } + + reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -139,20 +172,31 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { // GetRawEmail retrieves the raw annotated email // (GET /report/{id}/raw) -func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { - _, rawEmail, err := h.storage.GetReport(id) +func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: utils.PtrTo(err.Error()), + }) + return + } + + _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -160,6 +204,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { c.Data(http.StatusOK, "text/plain", rawEmail) } +// ReanalyzeReport re-analyzes an existing email and regenerates the report +// (POST /report/{id}/reanalyze) +func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Retrieve the existing report (mainly to get the raw email) + _, rawEmail, err := h.storage.GetReport(testUUID) + if err != nil { + if err == storage.ErrNotFound { + c.JSON(http.StatusNotFound, model.Error{ + Error: "not_found", + Message: "Email not found", + }) + return + } + c.JSON(http.StatusInternalServerError, model.Error{ + Error: "internal_error", + Message: "Failed to retrieve email", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Re-analyze the email using the current analyzer + reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error{ + Error: "analysis_error", + Message: "Failed to re-analyze email", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Update the report in storage + if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { + c.JSON(http.StatusInternalServerError, model.Error{ + Error: "internal_error", + Message: "Failed to update report", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Return the updated report JSON directly + c.Data(http.StatusOK, "application/json", reportJSON) +} + // GetStatus retrieves service health status // (GET /status) func (h *APIHandler) GetStatus(c *gin.Context) { @@ -167,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := StatusComponentsDatabaseUp + dbStatus := model.StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = StatusComponentsDatabaseDown + dbStatus = model.StatusComponentsDatabaseDown } // Determine overall status - overallStatus := Healthy - if dbStatus == StatusComponentsDatabaseDown { - overallStatus = Unhealthy + overallStatus := model.Healthy + if dbStatus == model.StatusComponentsDatabaseDown { + overallStatus = model.Unhealthy } - mtaStatus := StatusComponentsMtaUp - c.JSON(http.StatusOK, Status{ + mtaStatus := model.StatusComponentsMtaUp + c.JSON(http.StatusOK, model.Status{ Status: overallStatus, - Version: "0.1.0-dev", + Version: version.Version, Components: &struct { - Database *StatusComponentsDatabase `json:"database,omitempty"` - Mta *StatusComponentsMta `json:"mta,omitempty"` + Database *model.StatusComponentsDatabase `json:"database,omitempty"` + Mta *model.StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -192,3 +293,133 @@ func (h *APIHandler) GetStatus(c *gin.Context) { Uptime: &uptime, }) } + +// TestDomain performs synchronous domain analysis +// (POST /domain) +func (h *APIHandler) TestDomain(c *gin.Context) { + var request model.DomainTestRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Perform domain analysis + dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) + + // Convert grade string to DomainTestResponseGrade enum + var responseGrade model.DomainTestResponseGrade + switch grade { + case "A+": + responseGrade = model.DomainTestResponseGradeA + case "A": + responseGrade = model.DomainTestResponseGradeA1 + case "B": + responseGrade = model.DomainTestResponseGradeB + case "C": + responseGrade = model.DomainTestResponseGradeC + case "D": + responseGrade = model.DomainTestResponseGradeD + case "E": + responseGrade = model.DomainTestResponseGradeE + case "F": + responseGrade = model.DomainTestResponseGradeF + default: + responseGrade = model.DomainTestResponseGradeF + } + + // Build response + response := model.DomainTestResponse{ + Domain: request.Domain, + Score: score, + Grade: responseGrade, + DnsResults: *dnsResults, + } + + c.JSON(http.StatusOK, response) +} + +// CheckBlacklist checks an IP address against DNS blacklists +// (POST /blacklist) +func (h *APIHandler) CheckBlacklist(c *gin.Context) { + var request model.BlacklistCheckRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Perform blacklist check using analyzer + checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_ip", + Message: "Invalid IP address", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Build response + response := model.BlacklistCheckResponse{ + Ip: request.Ip, + Blacklists: checks, + Whitelists: &whitelists, + ListedCount: listedCount, + Score: score, + Grade: model.BlacklistCheckResponseGrade(grade), + } + + c.JSON(http.StatusOK, response) +} + +// ListTests returns a paginated list of test summaries +// (GET /tests) +func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { + if h.config.DisableTestList { + c.JSON(http.StatusForbidden, model.Error{ + Error: "feature_disabled", + Message: "Test listing is disabled on this instance", + }) + return + } + + offset := 0 + limit := 20 + if params.Offset != nil { + offset = *params.Offset + } + if params.Limit != nil { + limit = *params.Limit + if limit > 100 { + limit = 100 + } + } + + tests, total, err := h.storage.ListReportSummaries(offset, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error{ + Error: "internal_error", + Message: "Failed to list tests", + Details: utils.PtrTo(err.Error()), + }) + return + } + + c.JSON(http.StatusOK, model.TestListResponse{ + Tests: tests, + Total: int(total), + Offset: offset, + Limit: limit, + }) +} diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 2cccf1b..c704c56 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,7 +31,6 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/pkg/analyzer" ) @@ -87,57 +86,552 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { // outputHumanReadable outputs a human-readable summary func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { - // Header + report := result.Report + + // Header with overall score fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") fmt.Fprintln(writer, strings.Repeat("=", 70)) + fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade) + fmt.Fprintf(writer, "Test ID: %s\n", report.TestId) + fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST")) - // Score summary - summary := emailAnalyzer.GetScoreSummaryText(result) - fmt.Fprintln(writer, summary) + // Score Summary + if report.Summary != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SCORE BREAKDOWN") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - // Detailed checks - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "DETAILED CHECK RESULTS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - // Group checks by category - categories := make(map[api.CheckCategory][]api.Check) - for _, check := range result.Report.Checks { - categories[check.Category] = append(categories[check.Category], check) + summary := report.Summary + fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", + summary.DnsScore, summary.DnsGrade) + fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", + summary.AuthenticationScore, summary.AuthenticationGrade) + fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", + summary.BlacklistScore, summary.BlacklistGrade) + fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", + summary.HeaderScore, summary.HeaderGrade) + fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", + summary.SpamScore, summary.SpamGrade) + fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", + summary.ContentScore, summary.ContentGrade) } - // Print checks by category - categoryOrder := []api.CheckCategory{ - api.Authentication, - api.Dns, - api.Blacklist, - api.Content, - api.Headers, - } + // DNS Results + if report.DnsResults != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DNS CONFIGURATION") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - for _, category := range categoryOrder { - checks, ok := categories[category] - if !ok || len(checks) == 0 { - continue + dns := report.DnsResults + fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) + if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { + fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) } - fmt.Fprintf(writer, "\n%s:\n", category) - for _, check := range checks { - statusSymbol := "✓" - if check.Status == api.CheckStatusFail { - statusSymbol = "✗" - } else if check.Status == api.CheckStatusWarn { - statusSymbol = "⚠" + // MX Records + if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { + fmt.Fprintln(writer, "\n MX Records (From Domain):") + for _, mx := range *dns.FromMxRecords { + status := "✓" + if !mx.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) + if mx.Error != nil { + fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) + } + fmt.Fprintln(writer) } + } - fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message) - if check.Advice != nil && *check.Advice != "" { - fmt.Fprintf(writer, " → %s\n", *check.Advice) + // SPF Records + if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { + fmt.Fprintln(writer, "\n SPF Records:") + for _, spf := range *dns.SpfRecords { + status := "✓" + if !spf.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s ", status) + if spf.Domain != nil { + fmt.Fprintf(writer, "Domain: %s", *spf.Domain) + } + if spf.AllQualifier != nil { + fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) + } + fmt.Fprintln(writer) + if spf.Record != nil { + fmt.Fprintf(writer, " %s\n", *spf.Record) + } + if spf.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) + } + } + } + + // DKIM Records + if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { + fmt.Fprintln(writer, "\n DKIM Records:") + for _, dkim := range *dns.DkimRecords { + status := "✓" + if !dkim.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) + if dkim.Record != nil { + fmt.Fprintf(writer, " %s\n", *dkim.Record) + } + if dkim.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) + } + } + } + + // DMARC Record + if dns.DmarcRecord != nil { + fmt.Fprintln(writer, "\n DMARC Record:") + status := "✓" + if !dns.DmarcRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) + if dns.DmarcRecord.Policy != nil { + fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) + } + if dns.DmarcRecord.SubdomainPolicy != nil { + fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) + } + if dns.DmarcRecord.NonexistentSubdomainPolicy != nil { + fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy) + } + fmt.Fprintln(writer) + if dns.DmarcRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) + } + if dns.DmarcRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) + } + } + + // BIMI Record + if dns.BimiRecord != nil { + fmt.Fprintln(writer, "\n BIMI Record:") + status := "✓" + if !dns.BimiRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", + status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) + if dns.BimiRecord.LogoUrl != nil { + fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) + } + if dns.BimiRecord.VmcUrl != nil { + fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) + } + if dns.BimiRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) + } + if dns.BimiRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) + } + } + + // PTR Records + if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { + fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") + for _, ptr := range *dns.PtrRecords { + fmt.Fprintf(writer, " %s\n", ptr) + } + } + + // DNS Errors + if dns.Errors != nil && len(*dns.Errors) > 0 { + fmt.Fprintln(writer, "\n DNS Errors:") + for _, err := range *dns.Errors { + fmt.Fprintf(writer, " ! %s\n", err) } } } + // Authentication Results + if report.Authentication != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "AUTHENTICATION RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + auth := report.Authentication + + // SPF + if auth.Spf != nil { + fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) + if auth.Spf.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) + } + if auth.Spf.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) + } + fmt.Fprintln(writer) + } + + // DKIM + if auth.Dkim != nil && len(*auth.Dkim) > 0 { + fmt.Fprintln(writer, "\n DKIM:") + for i, dkim := range *auth.Dkim { + fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) + if dkim.Domain != nil { + fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) + if dkim.Selector != nil { + fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) + } + fmt.Fprintf(writer, ")") + } + if dkim.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) + } + fmt.Fprintln(writer) + } + } + + // DMARC + if auth.Dmarc != nil { + fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) + if auth.Dmarc.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) + } + if auth.Dmarc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) + } + fmt.Fprintln(writer) + } + + // ARC + if auth.Arc != nil { + fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) + if auth.Arc.ChainLength != nil { + fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) + } + if auth.Arc.ChainValid != nil { + fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) + } + if auth.Arc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) + } + fmt.Fprintln(writer) + } + + // BIMI + if auth.Bimi != nil { + fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) + if auth.Bimi.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) + } + if auth.Bimi.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) + } + fmt.Fprintln(writer) + } + + // IP Reverse + if auth.Iprev != nil { + fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) + if auth.Iprev.Ip != nil { + fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) + if auth.Iprev.Hostname != nil { + fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) + } + fmt.Fprintf(writer, ")") + } + if auth.Iprev.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) + } + fmt.Fprintln(writer) + } + } + + // Blacklist Results + if report.Blacklists != nil && len(*report.Blacklists) > 0 { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "BLACKLIST CHECKS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + totalChecks := 0 + totalListed := 0 + for ip, checks := range *report.Blacklists { + totalChecks += len(checks) + fmt.Fprintf(writer, "\n IP Address: %s\n", ip) + for _, check := range checks { + status := "✓" + if check.Listed { + status = "✗" + totalListed++ + } + fmt.Fprintf(writer, " %s %s", status, check.Rbl) + if check.Listed { + fmt.Fprintf(writer, " - LISTED") + if check.Response != nil { + fmt.Fprintf(writer, " (%s)", *check.Response) + } + } else { + fmt.Fprintf(writer, " - OK") + } + fmt.Fprintln(writer) + if check.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) + } + } + } + fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) + } + + // Header Analysis + if report.HeaderAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "HEADER ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + header := report.HeaderAnalysis + + // Domain Alignment + if header.DomainAlignment != nil { + fmt.Fprintln(writer, "\n Domain Alignment:") + align := header.DomainAlignment + if align.FromDomain != nil { + fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) + if align.FromOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) + } + fmt.Fprintln(writer) + } + if align.ReturnPathDomain != nil { + fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) + if align.ReturnPathOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) + } + fmt.Fprintln(writer) + } + if align.Aligned != nil { + fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) + } + if align.RelaxedAligned != nil { + fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) + } + if align.Issues != nil && len(*align.Issues) > 0 { + fmt.Fprintln(writer, " Issues:") + for _, issue := range *align.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + + // Required/Important Headers + if header.Headers != nil { + fmt.Fprintln(writer, "\n Standard Headers:") + importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} + for _, hdrName := range importantHeaders { + if hdr, ok := (*header.Headers)[hdrName]; ok { + status := "✗" + if hdr.Present { + status = "✓" + } + fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) + if hdr.Present { + if hdr.Valid != nil && !*hdr.Valid { + fmt.Fprintf(writer, "INVALID") + } else { + fmt.Fprintf(writer, "OK") + } + if hdr.Importance != nil { + fmt.Fprintf(writer, " [%s]", *hdr.Importance) + } + } else { + fmt.Fprintf(writer, "MISSING") + } + fmt.Fprintln(writer) + if hdr.Issues != nil && len(*hdr.Issues) > 0 { + for _, issue := range *hdr.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + } + } + + // Header Issues + if header.Issues != nil && len(*header.Issues) > 0 { + fmt.Fprintln(writer, "\n Header Issues:") + for _, issue := range *header.Issues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + + // Received Chain + if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { + fmt.Fprintln(writer, "\n Email Path (Received Chain):") + for i, hop := range *header.ReceivedChain { + fmt.Fprintf(writer, " [%d] ", i+1) + if hop.From != nil { + fmt.Fprintf(writer, "%s", *hop.From) + if hop.Ip != nil { + fmt.Fprintf(writer, " (%s)", *hop.Ip) + } + } + if hop.By != nil { + fmt.Fprintf(writer, " -> %s", *hop.By) + } + fmt.Fprintln(writer) + if hop.Timestamp != nil { + fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) + } + } + } + } + + // SpamAssassin Results + if report.Spamassassin != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + sa := report.Spamassassin + fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) + if sa.IsSpam { + fmt.Fprintf(writer, " (SPAM)") + } else { + fmt.Fprintf(writer, " (HAM)") + } + fmt.Fprintln(writer) + + if sa.Version != nil { + fmt.Fprintf(writer, " Version: %s\n", *sa.Version) + } + + if len(sa.TestDetails) > 0 { + fmt.Fprintln(writer, "\n Triggered Tests:") + for _, test := range sa.TestDetails { + scoreStr := "+" + if test.Score < 0 { + scoreStr = "" + } + fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) + if test.Description != nil { + fmt.Fprintf(writer, "\n %s", *test.Description) + } + fmt.Fprintln(writer) + } + } + } + + // Content Analysis + if report.ContentAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "CONTENT ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + content := report.ContentAnalysis + + // Basic content info + fmt.Fprintln(writer, "\n Content Structure:") + if content.HasPlaintext != nil { + fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) + } + if content.HasHtml != nil { + fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) + } + if content.TextToImageRatio != nil { + fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) + } + + // Unsubscribe + if content.HasUnsubscribeLink != nil { + fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) + if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { + fmt.Fprintf(writer, " Unsubscribe Methods: ") + for i, method := range *content.UnsubscribeMethods { + if i > 0 { + fmt.Fprintf(writer, ", ") + } + fmt.Fprintf(writer, "%s", method) + } + fmt.Fprintln(writer) + } + } + + // Links + if content.Links != nil && len(*content.Links) > 0 { + fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) + for _, link := range *content.Links { + status := "" + switch link.Status { + case "valid": + status = "✓" + case "broken": + status = "✗" + case "suspicious": + status = "⚠" + case "redirected": + status = "→" + case "timeout": + status = "⏱" + } + fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) + if link.HttpCode != nil { + fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) + } + fmt.Fprintln(writer) + if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { + fmt.Fprintln(writer, " Redirect chain:") + for _, url := range *link.RedirectChain { + fmt.Fprintf(writer, " -> %s\n", url) + } + } + } + } + + // Images + if content.Images != nil && len(*content.Images) > 0 { + fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) + missingAlt := 0 + trackingPixels := 0 + for _, img := range *content.Images { + if !img.HasAlt { + missingAlt++ + } + if img.IsTrackingPixel != nil && *img.IsTrackingPixel { + trackingPixels++ + } + } + fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", + len(*content.Images)-missingAlt, len(*content.Images)) + if trackingPixels > 0 { + fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) + } + } + + // HTML Issues + if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { + fmt.Fprintln(writer, "\n Content Issues:") + for _, issue := range *content.HtmlIssues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) + if issue.Location != nil { + fmt.Fprintf(writer, " Location: %s\n", *issue.Location) + } + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + } + + // Footer fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + return nil } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..4b01fbb --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,156 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + fmt.Fprintln(os.Stderr, "Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) + fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) + failed++ + continue + } + + if exists { + fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) + failed++ + continue + } + + restored++ + } + + fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go index 332516b..7149f45 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -25,13 +25,16 @@ import ( "context" "log" "os" + "time" + ratelimit "github.com/JGLTechnologies/gin-rate-limit" "github.com/gin-gonic/gin" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" "git.happydns.org/happyDeliver/web" ) @@ -63,8 +66,11 @@ func RunServer(cfg *config.Config) error { } }() + // Create analyzer adapter for API + analyzerAdapter := analyzer.NewAPIAdapter(cfg) + // Create API handler - handler := api.NewAPIHandler(store, cfg) + handler := api.NewAPIHandler(store, cfg, analyzerAdapter) // Set up Gin router if os.Getenv("GIN_MODE") == "" { @@ -72,8 +78,30 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - // Register API routes apiGroup := router.Group("/api") + + if cfg.RateLimit > 0 { + // Set up rate limiting (2x to handle burst) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 2 * time.Second, + Limit: 2 * cfg.RateLimit, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + apiGroup.Use(rateLimiter) + } + + // Register API routes api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) diff --git a/internal/config/cli.go b/internal/config/cli.go index 93c18ce..fcc914f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,10 +34,17 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") + flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") + flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") + flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") + flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") + flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") + flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") + flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index 510aaa9..b264994 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "flag" "fmt" "log" + "net/url" "os" "path" "strings" @@ -33,6 +34,11 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +func getHostname() string { + h, _ := os.Hostname() + return h +} + // Config represents the application configuration type Config struct { DevProxy string @@ -41,6 +47,10 @@ type Config struct { Email EmailConfig Analysis AnalysisConfig ReportRetention time.Duration // How long to keep reports. 0 = keep forever + RateLimit uint // API rate limit (requests per second per IP) + SurveyURL url.URL // URL for user feedback survey + CustomLogoURL string // URL for custom logo image in the web UI + DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings @@ -54,13 +64,17 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string + ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis type AnalysisConfig struct { - DNSTimeout time.Duration - HTTPTimeout time.Duration - RBLs []string + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string + DNSWLs []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) } // DefaultConfig returns a configuration with sensible defaults @@ -69,6 +83,7 @@ func DefaultConfig() *Config { DevProxy: "", Bind: ":8080", ReportRetention: 0, // Keep reports forever by default + RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default) Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", @@ -77,11 +92,14 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", + ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, + DNSWLs: []string{}, + CheckAllIPs: false, // By default, only check the first IP }, } } diff --git a/internal/config/custom.go b/internal/config/custom.go index 9461632..97c8d71 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -23,6 +23,7 @@ package config import ( "fmt" + "net/url" "strings" ) @@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error { return nil } + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + *i.URL = *u + return nil +} diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go index 1d9a720..a9b36b9 100644 --- a/internal/lmtp/server.go +++ b/internal/lmtp/server.go @@ -92,6 +92,10 @@ func (s *Session) Data(r io.Reader) error { log.Printf("LMTP: Received %d bytes", len(emailData)) + // Prepend Return-Path header from envelope sender + returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from) + emailData = append([]byte(returnPath), emailData...) + // Process email for each recipient // LMTP requires per-recipient status, but go-smtp handles this internally for _, recipient := range s.recipients { diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 1132b54..f06f535 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,6 +22,7 @@ package receiver import ( + "encoding/base32" "encoding/json" "fmt" "io" @@ -95,7 +96,18 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return fmt.Errorf("failed to analyze email: %w", err) } - log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) + log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) + + // Warn if the last Received hop doesn't match the expected receiver hostname + if r.config.Email.ReceiverHostname != "" && + result.Report.HeaderAnalysis != nil && + result.Report.HeaderAnalysis.ReceivedChain != nil && + len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { + lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] + if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { + log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) + } + } // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) @@ -112,8 +124,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return nil } +// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID +// Hyphens are ignored during decoding +func base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens for decoding + encoded = strings.ReplaceAll(encoded, "-", "") + + // Convert to uppercase for Base32 decoding + encoded = strings.ToUpper(encoded) + + // Decode from Base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err) + } + + // Ensure we have exactly 16 bytes for UUID + if len(decoded) != 16 { + return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded)) + } + + // Convert bytes to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} + // extractTestID extracts the UUID from the test email address -// Expected format: test-@domain.com +// Expected format: test-@domain.com func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { // Remove angle brackets if present (e.g., ) email = strings.Trim(email, "<>") @@ -133,10 +171,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix) - // Parse UUID - testID, err := uuid.Parse(uuidStr) + // Decode Base32 to UUID + testID, err := base32ToUUID(uuidStr) if err != nil { - return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr) + return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err) } return testID, nil diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7c27279..86605df 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,6 +30,9 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -43,7 +46,9 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) + UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) + ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) // Close closes the database connection Close() error @@ -107,7 +112,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) { // GetReport retrieves a report by test ID, returning the raw JSON and email bytes func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { var dbReport Report - if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil { + if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrNotFound } @@ -117,6 +122,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { return dbReport.ReportJSON, dbReport.RawEmail, nil } +// UpdateReport updates the report JSON for an existing test ID +func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error { + result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON) + if result.Error != nil { + return fmt.Errorf("failed to update report: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + // DeleteOldReports deletes reports older than the specified time func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) @@ -126,6 +143,72 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } +// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary +type reportSummaryRow struct { + TestID uuid.UUID + Score int + Grade string + FromDomain string + CreatedAt time.Time +} + +// ListReportSummaries returns a paginated list of lightweight report summaries +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { + var total int64 + if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count reports: %w", err) + } + + if total == 0 { + return []model.TestSummary{}, 0, nil + } + + var selectExpr string + switch s.db.Dialector.Name() { + case "postgres": + selectExpr = `test_id, ` + + `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` + + `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + + `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + + `created_at` + case "sqlite": + selectExpr = `test_id, ` + + `json_extract(report_json, '$.score') as score, ` + + `json_extract(report_json, '$.grade') as grade, ` + + `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + + `created_at` + default: + return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") + } + + var rows []reportSummaryRow + err := s.db.Model(&Report{}). + Select(selectExpr). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Scan(&rows).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) + } + + summaries := make([]model.TestSummary, 0, len(rows)) + for _, r := range rows { + s := model.TestSummary{ + TestId: utils.UUIDToBase32(r.TestID), + Score: r.Score, + Grade: model.TestSummaryGrade(r.Grade), + CreatedAt: r.CreatedAt, + } + if r.FromDomain != "" { + s.FromDomain = utils.PtrTo(r.FromDomain) + } + summaries = append(summaries, s) + } + + return summaries, total, nil +} + // Close closes the database connection func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() @@ -134,3 +217,33 @@ func (s *DBStorage) Close() error { } return sqlDB.Close() } + +// GetAllReports retrieves all reports from the database +func GetAllReports(s Storage) ([]Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support GetAllReports") + } + + var reports []Report + if err := dbStorage.db.Find(&reports).Error; err != nil { + return nil, fmt.Errorf("failed to retrieve reports: %w", err) + } + + return reports, nil +} + +// CreateReportFromBackup creates a report from backup data, preserving timestamps +func CreateReportFromBackup(s Storage, report *Report) (*Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support CreateReportFromBackup") + } + + // Use Create to insert the report with all fields including timestamps + if err := dbStorage.db.Create(report).Error; err != nil { + return nil, fmt.Errorf("failed to create report from backup: %w", err) + } + + return report, nil +} diff --git a/internal/api/helpers.go b/internal/utils/ptr.go similarity index 91% rename from internal/api/helpers.go rename to internal/utils/ptr.go index cce306a..748d6ba 100644 --- a/internal/api/helpers.go +++ b/internal/utils/ptr.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain +// Copyright (c) 2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,11 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package api - -func stringPtr(s string) *string { - return &s -} +package utils // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go new file mode 100644 index 0000000..ebbbbdf --- /dev/null +++ b/internal/utils/uuid.go @@ -0,0 +1,75 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package utils + +import ( + "encoding/base32" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding) +// with hyphens every 7 characters for better readability +func UUIDToBase32(id uuid.UUID) string { + // Use RFC 4648 Base32 encoding (URL-safe) + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:]) + // Convert to lowercase for better readability + encoded = strings.ToLower(encoded) + + // Insert hyphens every 7 characters + var result strings.Builder + for i, char := range encoded { + if i > 0 && i%7 == 0 { + result.WriteRune('-') + } + result.WriteRune(char) + } + + return result.String() +} + +// Base32ToUUID converts a base32-encoded string back to a UUID +// Accepts strings with or without hyphens +func Base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens + encoded = strings.ReplaceAll(encoded, "-", "") + // Convert to uppercase for decoding + encoded = strings.ToUpper(encoded) + + // Decode base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err) + } + + // Ensure we have exactly 16 bytes for a UUID + if len(decoded) != 16 { + return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded)) + } + + // Convert byte slice to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a46c79f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,26 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package version + +// Version is the application version. It can be set at build time using ldflags: +// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3" +var Version = "(custom build)" diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 3588280..5f57df3 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -23,11 +23,12 @@ package analyzer import ( "bytes" + "encoding/json" "fmt" "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/config" ) @@ -40,9 +41,13 @@ type EmailAnalyzer struct { // NewEmailAnalyzer creates a new email analyzer with the given configuration func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { generator := NewReportGenerator( + cfg.Email.ReceiverHostname, cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, + cfg.Analysis.CheckAllIPs, + cfg.Analysis.RspamdAPIURL, ) return &EmailAnalyzer{ @@ -54,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { type AnalysisResult struct { Email *EmailMessage Results *AnalysisResults - Report *api.Report + Report *model.Report } // AnalyzeEmailBytes performs complete email analysis from raw bytes @@ -78,10 +83,68 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A }, nil } -// GetScoreSummaryText returns a human-readable score summary -func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { - if result == nil || result.Results == nil { - return "" - } - return a.generator.GetScoreSummaryText(result.Results) +// APIAdapter adapts the EmailAnalyzer to work with the API package +// This adapter implements the interface expected by the API handler +type APIAdapter struct { + analyzer *EmailAnalyzer +} + +// NewAPIAdapter creates a new API adapter for the email analyzer +func NewAPIAdapter(cfg *config.Config) *APIAdapter { + return &APIAdapter{ + analyzer: NewEmailAnalyzer(cfg), + } +} + +// AnalyzeEmailBytes performs analysis and returns JSON bytes directly +func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { + result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return nil, err + } + + // Marshal report to JSON + reportJSON, err := json.Marshal(result.Report) + if err != nil { + return nil, fmt.Errorf("failed to marshal report: %w", err) + } + + return reportJSON, nil +} + +// AnalyzeDomain performs DNS analysis for a domain and returns the results +func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { + // Perform DNS analysis + dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) + + // Calculate score + score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults) + + return dnsResults, score, grade +} + +// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { + // Check the IP against all configured RBLs + checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) + if err != nil { + return nil, nil, 0, 0, "", err + } + + // Calculate score using the existing function + // Create a minimal RBLResults structure for scoring + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ip: checks}, + IPsChecked: []string{ip}, + ListedCount: listedCount, + } + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) + + // Check the IP against all configured DNSWLs (informational only) + whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) + if err != nil { + whitelists = nil + } + + return checks, whitelists, listedCount, score, grade, nil } diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index d6fd600..bd8880d 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -22,27 +22,27 @@ package analyzer import ( - "fmt" - "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct{} +type AuthenticationAnalyzer struct { + receiverHostname string +} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{} +func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{receiverHostname: receiverHostname} } // AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { - results := &api.AuthenticationResults{} +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { + results := &model.AuthenticationResults{} // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults() + authHeaders := email.GetAuthenticationResults(a.receiverHostname) for _, header := range authHeaders { a.parseAuthenticationResultsHeader(header, results) } @@ -52,13 +52,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results.Spf = a.parseLegacySPF(email) } - if results.Dkim == nil || len(*results.Dkim) == 0 { - dkimResults := a.parseLegacyDKIM(email) - if len(dkimResults) > 0 { - results.Dkim = &dkimResults - } - } - // Parse ARC headers if not already parsed from Authentication-Results if results.Arc == nil { results.Arc = a.parseARCHeaders(email) @@ -72,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api // parseAuthenticationResultsHeader parses an Authentication-Results header // Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { // Split by semicolon to get individual results parts := strings.Split(header, ";") if len(parts) < 2 { @@ -98,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, dkimResult := a.parseDKIMResult(part) if dkimResult != nil { if results.Dkim == nil { - dkimList := []api.AuthResult{*dkimResult} + dkimList := []model.AuthResult{*dkimResult} results.Dkim = &dkimList } else { *results.Dkim = append(*results.Dkim, *dkimResult) @@ -126,382 +119,67 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Arc = a.parseARCResult(part) } } - } -} -// parseSPFResult parses SPF result from Authentication-Results -// Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`spf=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain - domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - email := matches[1] - // Extract domain from email - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseDKIMResult parses DKIM result from Authentication-Results -// Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseDMARCResult parses DMARC result from Authentication-Results -// Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dmarc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.from) - domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract details (action, policy, etc.) - var detailsParts []string - actionRe := regexp.MustCompile(`action=([^\s;]+)`) - if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { - detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, " ") - result.Details = &details - } - - return result -} - -// parseBIMIResult parses BIMI result from Authentication-Results -// Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`bimi=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.selector or selector) - selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseARCResult parses ARC result from Authentication-Results -// Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} - - // Extract result (pass, fail, none) - re := regexp.MustCompile(`arc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.ARCResultResult(resultStr) - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseARCHeaders parses ARC headers from email message -// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { - // Get all ARC-related headers - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - - // If no ARC headers present, return nil - if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { - return nil - } - - result := &api.ARCResult{ - Result: api.ARCResultResultNone, - } - - // Count the ARC chain length (number of sets) - chainLength := len(arcSeal) - result.ChainLength = &chainLength - - // Validate the ARC chain - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - result.ChainValid = &chainValid - - // Determine overall result - if chainLength == 0 { - result.Result = api.ARCResultResultNone - details := "No ARC chain present" - result.Details = &details - } else if !chainValid { - result.Result = api.ARCResultResultFail - details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) - result.Details = &details - } else { - result.Result = api.ARCResultResultPass - details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) - result.Details = &details - } - - return result -} - -// enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { - if arcResult == nil { - return - } - - // Get ARC headers - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - - // Set chain length if not already set - if arcResult.ChainLength == nil { - chainLength := len(arcSeal) - arcResult.ChainLength = &chainLength - } - - // Validate chain if not already validated - if arcResult.ChainValid == nil { - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - arcResult.ChainValid = &chainValid - } -} - -// validateARCChain validates the ARC chain for completeness -// Each instance should have all three headers with matching instance numbers -func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { - // All three header types should have the same count - if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { - return false - } - - if len(arcSeal) == 0 { - return true // No ARC chain is technically valid - } - - // Extract instance numbers from each header type - sealInstances := a.extractARCInstances(arcSeal) - sigInstances := a.extractARCInstances(arcMessageSig) - authInstances := a.extractARCInstances(arcAuthResults) - - // Check that all instance numbers match and are sequential starting from 1 - if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { - return false - } - - // Verify instances are sequential from 1 to N - for i := 1; i <= len(sealInstances); i++ { - if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) { - return false - } - } - - return true -} - -// extractARCInstances extracts instance numbers from ARC headers -func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { - var instances []int - re := regexp.MustCompile(`i=(\d+)`) - - for _, header := range headers { - if matches := re.FindStringSubmatch(header); len(matches) > 1 { - var instance int - fmt.Sscanf(matches[1], "%d", &instance) - instances = append(instances, instance) - } - } - - return instances -} - -// contains checks if a slice contains an integer -func contains(slice []int, val int) bool { - for _, item := range slice { - if item == val { - return true - } - } - return false -} - -// pluralize returns "y" or "ies" based on count -func pluralize(count int) string { - if count == 1 { - return "y" - } - return "ies" -} - -// parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { - receivedSPF := email.Header.Get("Received-SPF") - if receivedSPF == "" { - return nil - } - - result := &api.AuthResult{} - - // Extract result (first word) - parts := strings.Fields(receivedSPF) - if len(parts) > 0 { - resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) - } - - // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - email := matches[1] - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - return result -} - -// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header -func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { - var results []api.AuthResult - - // Get all DKIM-Signature headers - dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] - for _, dkimHeader := range dkimHeaders { - result := api.AuthResult{ - Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone + // Parse IPRev + if strings.HasPrefix(part, "iprev=") { + if results.Iprev == nil { + results.Iprev = a.parseIPRevResult(part) + } } - // Extract domain (d=) - domainRe := regexp.MustCompile(`d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain + // Parse x-google-dkim + if strings.HasPrefix(part, "x-google-dkim=") { + if results.XGoogleDkim == nil { + results.XGoogleDkim = a.parseXGoogleDKIMResult(part) + } } - // Extract selector (s=) - selectorRe := regexp.MustCompile(`s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - details := "DKIM signature present (verification status unknown)" - result.Details = &details - - results = append(results, result) - } - - return results -} - -// textprotoCanonical converts a header name to canonical form -func textprotoCanonical(s string) string { - // Simple implementation - capitalize each word - words := strings.Split(s, "-") - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + // Parse x-aligned-from + if strings.HasPrefix(part, "x-aligned-from=") { + if results.XAlignedFrom == nil { + results.XAlignedFrom = a.parseXAlignedFromResult(part) + } } } - return strings.Join(words, "-") +} + +// CalculateAuthenticationScore calculates the authentication score from auth results +// Returns a score from 0-100 where higher is better +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { + if results == nil { + return 0, "" + } + + score := 0 + + // Core authentication (90 points total) + // SPF (30 points) + score += 30 * a.calculateSPFScore(results) / 100 + + // DKIM (30 points) + score += 30 * a.calculateDKIMScore(results) / 100 + + // DMARC (30 points) + score += 30 * a.calculateDMARCScore(results) / 100 + + // BIMI (10 points) + score += 10 * a.calculateBIMIScore(results) / 100 + + // Penalty-only: IPRev (up to -7 points on failure) + if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 { + score += 7 * (iprevScore - 100) / 100 + } + + // Penalty-only: X-Google-DKIM (up to -12 points on failure) + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 + + // Penalty-only: X-Aligned-From (up to -5 points on failure) + score += 5 * a.calculateXAlignedFromScore(results) / 100 + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + + return score, ScoreToGrade(score) } diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go new file mode 100644 index 0000000..e7333ce --- /dev/null +++ b/pkg/analyzer/authentication_arc.go @@ -0,0 +1,184 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult { + result := &model.ARCResult{} + + // Extract result (pass, fail, none) + re := regexp.MustCompile(`arc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.ARCResultResult(resultStr) + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc=")) + + return result +} + +// parseARCHeaders parses ARC headers from email message +// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &model.ARCResult{ + Result: model.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = model.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = model.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = model.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go new file mode 100644 index 0000000..ac51d0b --- /dev/null +++ b/pkg/analyzer/authentication_arc_test.go @@ -0,0 +1,150 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.ARCResultResult + }{ + { + name: "ARC pass", + part: "arc=pass", + expectedResult: model.ARCResultResultPass, + }, + { + name: "ARC fail", + part: "arc=fail", + expectedResult: model.ARCResultResultFail, + }, + { + name: "ARC none", + part: "arc=none", + expectedResult: model.ARCResultResultNone, + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + }) + } +} + +func TestValidateARCChain(t *testing.T) { + tests := []struct { + name string + arcAuthResults []string + arcMessageSig []string + arcSeal []string + expectedValid bool + }{ + { + name: "Empty chain is valid", + arcAuthResults: []string{}, + arcMessageSig: []string{}, + arcSeal: []string{}, + expectedValid: true, + }, + { + name: "Valid chain with single hop", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + }, + expectedValid: true, + }, + { + name: "Valid chain with two hops", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=2; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=2; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=2; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: true, + }, + { + name: "Invalid chain - missing one header type", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{}, + expectedValid: false, + }, + { + name: "Invalid chain - non-sequential instances", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=3; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=3; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=3; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: false, + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) + + if valid != tt.expectedValid { + t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) + } + }) + } +} diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go new file mode 100644 index 0000000..9654ac7 --- /dev/null +++ b/pkg/analyzer/authentication_bimi.go @@ -0,0 +1,76 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { + if results.Bimi != nil { + switch results.Bimi.Result { + case model.AuthResultResultPass: + return 100 + case model.AuthResultResultDeclined: + return 59 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go new file mode 100644 index 0000000..440f356 --- /dev/null +++ b/pkg/analyzer/authentication_bimi_test.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "BIMI pass with domain and selector", + part: "bimi=pass header.d=example.com header.selector=default", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: model.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI with short form (d= and selector=)", + part: "bimi=pass d=example.com selector=v1", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: model.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go deleted file mode 100644 index 01298a0..0000000 --- a/pkg/analyzer/authentication_checks.go +++ /dev/null @@ -1,304 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "strings" - - "git.happydns.org/happyDeliver/internal/api" -) - -// GenerateAuthenticationChecks generates check results for authentication -func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { - var checks []api.Check - - // SPF check - if results.Spf != nil { - check := a.generateSPFCheck(results.Spf) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "SPF Record", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No SPF authentication result found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), - }) - } - - // DKIM check - if results.Dkim != nil && len(*results.Dkim) > 0 { - for i, dkim := range *results.Dkim { - check := a.generateDKIMCheck(&dkim, i) - checks = append(checks, check) - } - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DKIM Signature", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DKIM signature found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), - }) - } - - // DMARC check - if results.Dmarc != nil { - check := a.generateDMARCCheck(results.Dmarc) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DMARC authentication result found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Implement DMARC policy for your domain"), - }) - } - - // BIMI check (optional, informational only) - if results.Bimi != nil { - check := a.generateBIMICheck(results.Bimi) - checks = append(checks, check) - } - - // ARC check (optional, for forwarded emails) - if results.Arc != nil { - check := a.generateARCCheck(results.Arc) - checks = append(checks, check) - } - - return checks -} - -func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "SPF Record", - } - - switch spf.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your SPF record is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "SPF validation failed" - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") - case api.AuthResultResultSoftfail: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation softfail" - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Review your SPF record configuration") - case api.AuthResultResultNeutral: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation neutral" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("Consider tightening your SPF policy") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Review your SPF record configuration") - } - - if spf.Domain != nil { - details := fmt.Sprintf("Domain: %s", *spf.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: fmt.Sprintf("DKIM Signature #%d", index+1), - } - - switch dkim.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DKIM signature is valid" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your DKIM signature is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DKIM signature validation failed" - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") - } - - var detailsParts []string - if dkim.Domain != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) - } - if dkim.Selector != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) - } - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - } - - switch dmarc.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DMARC validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your DMARC policy is properly aligned") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DMARC validation failed" - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Configure DMARC policy for your domain") - } - - if dmarc.Domain != nil { - details := fmt.Sprintf("Domain: %s", *dmarc.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "BIMI (Brand Indicators)", - } - - switch bimi.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) - check.Message = "BIMI validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") - case api.AuthResultResultFail: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = "BIMI validation failed" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") - default: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") - } - - if bimi.Domain != nil { - details := fmt.Sprintf("Domain: %s", *bimi.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "ARC (Authenticated Received Chain)", - } - - switch arc.Result { - case api.ARCResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) - check.Message = "ARC chain validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication") - case api.ARCResultResultFail: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = "ARC chain validation failed" - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries") - default: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = "No ARC chain present" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries") - } - - // Build details - var detailsParts []string - if arc.ChainLength != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength)) - } - if arc.ChainValid != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid)) - } - if arc.Details != nil { - detailsParts = append(detailsParts, *arc.Details) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go new file mode 100644 index 0000000..4165d8b --- /dev/null +++ b/pkg/analyzer/authentication_dkim.go @@ -0,0 +1,87 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseDKIMResult parses DKIM result from Authentication-Results +// Example: dkim=pass header.d=example.com header.s=selector1 +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) { + // Expect at least one passing signature + if results.Dkim != nil && len(*results.Dkim) > 0 { + hasPass := false + hasNonPass := false + for _, dkim := range *results.Dkim { + if dkim.Result == model.AuthResultResultPass { + hasPass = true + } else { + hasNonPass = true + } + } + if hasPass && hasNonPass { + // Could be better + return 90 + } else if hasPass { + return 100 + } else { + // Has DKIM signatures but none passed + return 20 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go new file mode 100644 index 0000000..0576854 --- /dev/null +++ b/pkg/analyzer/authentication_dkim_test.go @@ -0,0 +1,86 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "DKIM pass with domain and selector", + part: "dkim=pass header.d=example.com header.s=default", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "DKIM fail", + part: "dkim=fail header.d=example.com header.s=selector1", + expectedResult: model.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "selector1", + }, + { + name: "DKIM with short form (d= and s=)", + part: "dkim=pass d=example.com s=default", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + }) + } +} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go new file mode 100644 index 0000000..c89093d --- /dev/null +++ b/pkg/analyzer/authentication_dmarc.go @@ -0,0 +1,69 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseDMARCResult parses DMARC result from Authentication-Results +// Example: dmarc=pass action=none header.from=example.com +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dmarc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { + if results.Dmarc != nil { + switch results.Dmarc.Result { + case model.AuthResultResultPass: + return 100 + case model.AuthResultResultNone: + return 33 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go new file mode 100644 index 0000000..69779a7 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -0,0 +1,69 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: model.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go new file mode 100644 index 0000000..3ed045c --- /dev/null +++ b/pkg/analyzer/authentication_iprev.go @@ -0,0 +1,74 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseIPRevResult parses IP reverse lookup result from Authentication-Results +// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult { + result := &model.IPRevResult{} + + // Extract result (pass, fail, temperror, permerror, none) + re := regexp.MustCompile(`iprev=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.IPRevResultResult(resultStr) + } + + // Extract IP address (smtp.remote-ip or remote-ip) + ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) + if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { + ip := matches[1] + result.Ip = &ip + } + + // Extract hostname from parentheses + hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) + if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { + hostname := matches[1] + result.Hostname = &hostname + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { + if results.Iprev != nil { + switch results.Iprev.Result { + case model.Pass: + return 100 + default: // fail, temperror, permerror + return 0 + } + } + + return 100 +} diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go new file mode 100644 index 0000000..55f85d5 --- /dev/null +++ b/pkg/analyzer/authentication_iprev_test.go @@ -0,0 +1,226 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +func TestParseIPRevResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass with IP and hostname", + part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedResult: model.Pass, + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev pass without smtp prefix", + part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", + expectedResult: model.Fail, + expectedIP: utils.PtrTo("198.51.100.42"), + expectedHostname: utils.PtrTo("unknown.host.com"), + }, + { + name: "IPRev temperror", + part: "iprev=temperror smtp.remote-ip=203.0.113.1", + expectedResult: model.Temperror, + expectedIP: utils.PtrTo("203.0.113.1"), + expectedHostname: nil, + }, + { + name: "IPRev permerror", + part: "iprev=permerror smtp.remote-ip=192.0.2.100", + expectedResult: model.Permerror, + expectedIP: utils.PtrTo("192.0.2.100"), + expectedHostname: nil, + }, + { + name: "IPRev with IPv6", + part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", + expectedResult: model.Pass, + expectedIP: utils.PtrTo("2001:db8::1"), + expectedHostname: utils.PtrTo("ipv6.example.com"), + }, + { + name: "IPRev with subdomain hostname", + part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.50"), + expectedHostname: utils.PtrTo("mail.subdomain.example.com"), + }, + { + name: "IPRev pass without parentheses", + part: "iprev=pass smtp.remote-ip=192.0.2.200", + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.200"), + expectedHostname: nil, + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseIPRevResult(tt.part) + + // Check result + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + // Check IP + if tt.expectedIP != nil { + if result.Ip == nil { + t.Errorf("IP = nil, want %v", *tt.expectedIP) + } else if *result.Ip != *tt.expectedIP { + t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) + } + } else { + if result.Ip != nil { + t.Errorf("IP = %v, want nil", *result.Ip) + } + } + + // Check hostname + if tt.expectedHostname != nil { + if result.Hostname == nil { + t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) + } else if *result.Hostname != *tt.expectedHostname { + t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) + } + } else { + if result.Hostname != nil { + t.Errorf("Hostname = %v, want nil", *result.Hostname) + } + } + + // Check details + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } + }) + } +} + +func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { + tests := []struct { + name string + header string + expectedIPRevResult *model.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass in Authentication-Results", + header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev with other authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", + expectedIPRevResult: utils.PtrTo(model.Fail), + expectedIP: utils.PtrTo("198.51.100.42"), + expectedHostname: nil, + }, + { + name: "No IPRev in header", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", + expectedIPRevResult: nil, + }, + { + name: "Multiple IPRev results - only first is parsed", + header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("first.com"), + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check IPRev + if tt.expectedIPRevResult != nil { + if results.Iprev == nil { + t.Errorf("Expected IPRev result, got nil") + } else { + if results.Iprev.Result != *tt.expectedIPRevResult { + t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) + } + if tt.expectedIP != nil { + if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { + var gotIP string + if results.Iprev.Ip != nil { + gotIP = *results.Iprev.Ip + } + t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) + } + } + if tt.expectedHostname != nil { + if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { + var gotHostname string + if results.Iprev.Hostname != nil { + gotHostname = *results.Iprev.Hostname + } + t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) + } + } + } + } else { + if results.Iprev != nil { + t.Errorf("Expected no IPRev result, got %+v", results.Iprev) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go new file mode 100644 index 0000000..1488c98 --- /dev/null +++ b/pkg/analyzer/authentication_spf.go @@ -0,0 +1,116 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseSPFResult parses SPF result from Authentication-Results +// Example: spf=pass smtp.mailfrom=sender@example.com +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`spf=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf=")) + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + // Verify receiver matches our hostname + if a.receiverHostname != "" { + receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`) + if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + if matches[1] != a.receiverHostname { + return nil + } + } + } + + result := &model.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = model.AuthResultResult(resultStr) + } + + result.Details = &receivedSPF + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { + if results.Spf != nil { + switch results.Spf.Result { + case model.AuthResultResultPass: + return 100 + case model.AuthResultResultNeutral, model.AuthResultResultNone: + return 50 + case model.AuthResultResultSoftfail: + return 17 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go new file mode 100644 index 0000000..210505a --- /dev/null +++ b/pkg/analyzer/authentication_spf_test.go @@ -0,0 +1,213 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: model.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: model.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: model.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: model.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseLegacySPF(t *testing.T) { + tests := []struct { + name string + receivedSPF string + expectedResult model.AuthResultResult + expectedDomain *string + expectNil bool + }{ + { + name: "SPF pass with envelope-from", + receivedSPF: `pass + (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) + receiver=mx.receiver.com; + identity=mailfrom; + envelope-from="user@example.com"; + helo=smtp.example.com; + client-ip=192.0.2.10`, + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("example.com"), + }, + { + name: "SPF fail with sender", + receivedSPF: `fail + (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) + receiver=mx.receiver.com; + identity=mailfrom; + sender="sender@test.com"; + helo=smtp.test.com; + client-ip=192.0.2.20`, + expectedResult: model.AuthResultResultFail, + expectedDomain: utils.PtrTo("test.com"), + }, + { + name: "SPF softfail", + receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", + expectedResult: model.AuthResultResultSoftfail, + expectedDomain: utils.PtrTo("example.org"), + }, + { + name: "SPF neutral", + receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", + expectedResult: model.AuthResultResultNeutral, + expectedDomain: utils.PtrTo("domain.net"), + }, + { + name: "SPF none", + receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", + expectedResult: model.AuthResultResultNone, + expectedDomain: utils.PtrTo("company.io"), + }, + { + name: "SPF temperror", + receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", + expectedResult: model.AuthResultResultTemperror, + expectedDomain: utils.PtrTo("shop.example"), + }, + { + name: "SPF permerror", + receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", + expectedResult: model.AuthResultResultPermerror, + expectedDomain: utils.PtrTo("invalid.test"), + }, + { + name: "SPF pass without domain extraction", + receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", + expectedResult: model.AuthResultResultPass, + expectedDomain: nil, + }, + { + name: "Empty Received-SPF header", + receivedSPF: "", + expectNil: true, + }, + { + name: "SPF with unquoted envelope-from", + receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("mail.example.net"), + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with Received-SPF header + email := &EmailMessage{ + Header: make(map[string][]string), + } + if tt.receivedSPF != "" { + email.Header["Received-Spf"] = []string{tt.receivedSPF} + } + + result := analyzer.parseLegacySPF(email) + + if tt.expectNil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result, got nil") + } + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if tt.expectedDomain != nil { + if result.Domain == nil { + t.Errorf("Domain = nil, want %v", *tt.expectedDomain) + } else if *result.Domain != *tt.expectedDomain { + t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) + } + } else { + if result.Domain != nil { + t.Errorf("Domain = %v, want nil", *result.Domain) + } + } + + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } else if *result.Details != tt.receivedSPF { + t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) + } + }) + } +} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 17ac24e..0b17bf0 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -22,552 +22,90 @@ package analyzer import ( - "strings" "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) -func TestParseSPFResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - }{ - { - name: "SPF pass with domain", - part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "SPF fail", - part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "SPF neutral", - part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: "example.com", - }, - { - name: "SPF softfail", - part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseSPFResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} - -func TestParseDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "DKIM pass with domain and selector", - part: "dkim=pass header.d=example.com header.s=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "DKIM fail", - part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - expectedSelector: "selector1", - }, - { - name: "DKIM with short form (d= and s=)", - part: "dkim=pass d=example.com s=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - }) - } -} - -func TestParseDMARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - }{ - { - name: "DMARC pass", - part: "dmarc=pass action=none header.from=example.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "DMARC fail", - part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} - -func TestParseBIMIResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "BIMI pass with domain and selector", - part: "bimi=pass header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "BIMI fail", - part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "BIMI with short form (d= and selector=)", - part: "bimi=pass d=example.com selector=v1", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "v1", - }, - { - name: "BIMI none", - part: "bimi=none header.d=example.com", - expectedResult: api.AuthResultResultNone, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseBIMIResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if tt.expectedSelector != "" { - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - } - }) - } -} - -func TestGenerateAuthSPFCheck(t *testing.T) { - tests := []struct { - name string - spf *api.AuthResult - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "SPF pass", - spf: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "SPF fail", - spf: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "SPF softfail", - spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - { - name: "SPF neutral", - spf: &api.AuthResult{ - Result: api.AuthResultResultNeutral, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateSPFCheck(tt.spf) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if check.Name != "SPF Record" { - t.Errorf("Name = %q, want %q", check.Name, "SPF Record") - } - }) - } -} - -func TestGenerateAuthDKIMCheck(t *testing.T) { - tests := []struct { - name string - dkim *api.AuthResult - index int - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "DKIM pass", - dkim: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - Selector: api.PtrTo("default"), - }, - index: 0, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "DKIM fail", - dkim: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - Selector: api.PtrTo("default"), - }, - index: 0, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "DKIM none", - dkim: &api.AuthResult{ - Result: api.AuthResultResultNone, - Domain: api.PtrTo("example.com"), - Selector: api.PtrTo("default"), - }, - index: 0, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDKIMCheck(tt.dkim, tt.index) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if !strings.Contains(check.Name, "DKIM Signature") { - t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name) - } - }) - } -} - -func TestGenerateAuthDMARCCheck(t *testing.T) { - tests := []struct { - name string - dmarc *api.AuthResult - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "DMARC pass", - dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "DMARC fail", - dmarc: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDMARCCheck(tt.dmarc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if check.Name != "DMARC Policy" { - t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy") - } - }) - } -} - -func TestGenerateAuthBIMICheck(t *testing.T) { - tests := []struct { - name string - bimi *api.AuthResult - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "BIMI pass", - bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score - }, - { - name: "BIMI fail", - bimi: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, - }, - { - name: "BIMI none", - bimi: &api.AuthResult{ - Result: api.AuthResultResultNone, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateBIMICheck(tt.bimi) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if check.Name != "BIMI (Brand Indicators)" { - t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)") - } - - // BIMI should always have score of 0.0 (branding feature) - if check.Score != 0.0 { - t.Error("BIMI should not contribute to deliverability score") - } - }) - } -} - func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string - results *api.AuthenticationResults - expectedScore float32 + results *model.AuthenticationResults + expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, + Dmarc: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, - expectedScore: 3.0, + expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30 }, { name: "SPF and DKIM only", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, - expectedScore: 2.0, + expectedScore: 60, // SPF=30 + DKIM=30 }, { name: "SPF fail, DKIM pass", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultFail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultFail, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, - expectedScore: 1.0, + expectedScore: 30, // SPF=0 + DKIM=30 }, { name: "SPF softfail", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultSoftfail, }, }, - expectedScore: 0.5, + expectedScore: 5, // 30 * 17 / 100 = 5 }, { name: "No authentication", - results: &api.AuthenticationResults{}, - expectedScore: 0.0, + results: &model.AuthenticationResults{}, + expectedScore: 0, }, { - name: "BIMI doesn't affect score", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + name: "BIMI adds to score", + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, + Bimi: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, - expectedScore: 1.0, // Only SPF counted, not BIMI + expectedScore: 40, // SPF (30) + BIMI (10) }, } - scorer := NewDeliverabilityScorer() + scorer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := scorer.GetAuthenticationScore(tt.results) + score, _ := scorer.CalculateAuthenticationScore(tt.results) if score != tt.expectedScore { t.Errorf("Score = %v, want %v", score, tt.expectedScore) @@ -576,271 +114,326 @@ func TestGetAuthenticationScore(t *testing.T) { } } -func TestGenerateAuthenticationChecks(t *testing.T) { +func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { - name string - results *api.AuthenticationResults - expectedChecks int + name string + header string + expectedSPFResult *model.AuthResultResult + expectedSPFDomain *string + expectedDKIMCount int + expectedDKIMResult *model.AuthResultResult + expectedDMARCResult *model.AuthResultResult + expectedDMARCDomain *string + expectedBIMIResult *model.AuthResultResult + expectedARCResult *model.ARCResultResult }{ { - name: "All authentication methods present", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - Bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - }, - expectedChecks: 4, // SPF, DKIM, DMARC, BIMI + name: "Complete authentication results", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { - name: "Without BIMI", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - }, - expectedChecks: 3, // SPF, DKIM, DMARC + name: "SPF only", + header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("domain.com"), + expectedDKIMCount: 0, + expectedDMARCResult: nil, }, { - name: "No authentication results", - results: &api.AuthenticationResults{}, - expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing + name: "DKIM only", + header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { - name: "With ARC", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, - }, - Arc: &api.ARCResult{ - Result: api.ARCResultResultPass, - ChainLength: api.PtrTo(2), - ChainValid: api.PtrTo(true), - }, - }, - expectedChecks: 4, // SPF, DKIM, DMARC, ARC + name: "Multiple DKIM signatures", + header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", + expectedSPFResult: nil, + expectedDKIMCount: 2, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: nil, + }, + { + name: "SPF fail with DKIM pass", + header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default", + expectedSPFResult: utils.PtrTo(model.AuthResultResultFail), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: nil, + }, + { + name: "SPF softfail", + header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", + expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 0, + expectedDMARCResult: nil, + }, + { + name: "DMARC fail", + header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail), + expectedDMARCDomain: utils.PtrTo("example.com"), + }, + { + name: "BIMI pass", + header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 0, + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), + }, + { + name: "ARC pass", + header: "mail.example.com; arc=pass", + expectedSPFResult: nil, + expectedDKIMCount: 0, + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), + }, + { + name: "All authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), + }, + { + name: "Empty header (authserv-id only)", + header: "mx.google.com", + expectedSPFResult: nil, + expectedDKIMCount: 0, + }, + { + name: "Empty parts with semicolons", + header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "DKIM with short form parameters", + header: "mail.example.com; dkim=pass d=example.com s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + }, + { + name: "SPF neutral", + header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", + expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), + expectedSPFDomain: utils.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "SPF none", + header: "mail.example.com; spf=none", + expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), + expectedDKIMCount: 0, }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateAuthenticationChecks(tt.results) + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) - if len(checks) != tt.expectedChecks { - t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks) + // Check SPF + if tt.expectedSPFResult != nil { + if results.Spf == nil { + t.Errorf("Expected SPF result, got nil") + } else { + if results.Spf.Result != *tt.expectedSPFResult { + t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult) + } + if tt.expectedSPFDomain != nil { + if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain { + var gotDomain string + if results.Spf.Domain != nil { + gotDomain = *results.Spf.Domain + } + t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain) + } + } + } + } else { + if results.Spf != nil { + t.Errorf("Expected no SPF result, got %+v", results.Spf) + } } - // Verify all checks have the Authentication category - for _, check := range checks { - if check.Category != api.Authentication { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication) + // Check DKIM count and result + if results.Dkim == nil { + if tt.expectedDKIMCount != 0 { + t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount) + } + } else { + if len(*results.Dkim) != tt.expectedDKIMCount { + t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount) + } + if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 { + if (*results.Dkim)[0].Result != *tt.expectedDKIMResult { + t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult) + } + } + } + + // Check DMARC + if tt.expectedDMARCResult != nil { + if results.Dmarc == nil { + t.Errorf("Expected DMARC result, got nil") + } else { + if results.Dmarc.Result != *tt.expectedDMARCResult { + t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult) + } + if tt.expectedDMARCDomain != nil { + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain { + var gotDomain string + if results.Dmarc.Domain != nil { + gotDomain = *results.Dmarc.Domain + } + t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain) + } + } + } + } else { + if results.Dmarc != nil { + t.Errorf("Expected no DMARC result, got %+v", results.Dmarc) + } + } + + // Check BIMI + if tt.expectedBIMIResult != nil { + if results.Bimi == nil { + t.Errorf("Expected BIMI result, got nil") + } else { + if results.Bimi.Result != *tt.expectedBIMIResult { + t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult) + } + } + } else { + if results.Bimi != nil { + t.Errorf("Expected no BIMI result, got %+v", results.Bimi) + } + } + + // Check ARC + if tt.expectedARCResult != nil { + if results.Arc == nil { + t.Errorf("Expected ARC result, got nil") + } else { + if results.Arc.Result != *tt.expectedARCResult { + t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult) + } + } + } else { + if results.Arc != nil { + t.Errorf("Expected no ARC result, got %+v", results.Arc) } } }) } } -func TestParseARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.ARCResultResult - }{ - { - name: "ARC pass", - part: "arc=pass", - expectedResult: api.ARCResultResultPass, - }, - { - name: "ARC fail", - part: "arc=fail", - expectedResult: api.ARCResultResultFail, - }, - { - name: "ARC none", - part: "arc=none", - expectedResult: api.ARCResultResultNone, - }, - } +func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { + // This test verifies that only the first occurrence of each auth method is parsed + analyzer := NewAuthenticationAnalyzer("") - analyzer := NewAuthenticationAnalyzer() + t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseARCResult(tt.part) + if results.Spf == nil { + t.Fatal("Expected SPF result, got nil") + } + if results.Spf.Result != model.AuthResultResultPass { + t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) + } + if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { + t.Errorf("Expected domain from first SPF result") + } + }) - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - }) - } -} - -func TestValidateARCChain(t *testing.T) { - tests := []struct { - name string - arcAuthResults []string - arcMessageSig []string - arcSeal []string - expectedValid bool - }{ - { - name: "Empty chain is valid", - arcAuthResults: []string{}, - arcMessageSig: []string{}, - arcSeal: []string{}, - expectedValid: true, - }, - { - name: "Valid chain with single hop", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - }, - expectedValid: true, - }, - { - name: "Valid chain with two hops", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=2; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=2; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=2; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: true, - }, - { - name: "Invalid chain - missing one header type", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{}, - expectedValid: false, - }, - { - name: "Invalid chain - non-sequential instances", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=3; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=3; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=3; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: false, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) - - if valid != tt.expectedValid { - t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) - } - }) - } -} - -func TestGenerateARCCheck(t *testing.T) { - tests := []struct { - name string - arc *api.ARCResult - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "ARC pass", - arc: &api.ARCResult{ - Result: api.ARCResultResultPass, - ChainLength: api.PtrTo(2), - ChainValid: api.PtrTo(true), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // ARC doesn't contribute to score - }, - { - name: "ARC fail", - arc: &api.ARCResult{ - Result: api.ARCResultResultFail, - ChainLength: api.PtrTo(1), - ChainValid: api.PtrTo(false), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, - }, - { - name: "ARC none", - arc: &api.ARCResult{ - Result: api.ARCResultResultNone, - ChainLength: api.PtrTo(0), - ChainValid: api.PtrTo(true), - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateARCCheck(tt.arc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if !strings.Contains(check.Name, "ARC") { - t.Errorf("Name should contain 'ARC', got %q", check.Name) - } - }) - } + t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dmarc == nil { + t.Fatal("Expected DMARC result, got nil") + } + if results.Dmarc.Result != model.AuthResultResultPass { + t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) + } + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { + t.Errorf("Expected domain from first DMARC result") + } + }) + + t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; arc=pass; arc=fail" + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Arc == nil { + t.Fatal("Expected ARC result, got nil") + } + if results.Arc.Result != model.ARCResultResultPass { + t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result) + } + }) + + t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com" + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Bimi == nil { + t.Fatal("Expected BIMI result, got nil") + } + if results.Bimi.Result != model.AuthResultResultPass { + t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) + } + if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { + t.Errorf("Expected domain from first BIMI result") + } + }) + + t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { + // DKIM is special - multiple signatures should all be collected + header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" + results := &model.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 2 { + t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != model.AuthResultResultPass { + t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[1].Result != model.AuthResultResultFail { + t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) + } + }) } diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go new file mode 100644 index 0000000..45c2e2e --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -0,0 +1,66 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results +// Example: x-aligned-from=pass (Address match) +func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-aligned-from=([\w]+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract details (everything after the result) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { + if results.XAlignedFrom != nil { + switch results.XAlignedFrom.Result { + case model.AuthResultResultPass: + // pass: no impact + return 0 + case model.AuthResultResultFail: + // fail: negative contribution + return -100 + default: + // neutral, none, etc.: no impact + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go new file mode 100644 index 0000000..ee90c0d --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -0,0 +1,144 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseXAlignedFromResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDetail string + }{ + { + name: "x-aligned-from pass with details", + part: "x-aligned-from=pass (Address match)", + expectedResult: model.AuthResultResultPass, + expectedDetail: "pass (Address match)", + }, + { + name: "x-aligned-from fail with reason", + part: "x-aligned-from=fail (Address mismatch)", + expectedResult: model.AuthResultResultFail, + expectedDetail: "fail (Address mismatch)", + }, + { + name: "x-aligned-from pass minimal", + part: "x-aligned-from=pass", + expectedResult: model.AuthResultResultPass, + expectedDetail: "pass", + }, + { + name: "x-aligned-from neutral", + part: "x-aligned-from=neutral (No alignment check performed)", + expectedResult: model.AuthResultResultNeutral, + expectedDetail: "neutral (No alignment check performed)", + }, + { + name: "x-aligned-from none", + part: "x-aligned-from=none", + expectedResult: model.AuthResultResultNone, + expectedDetail: "none", + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXAlignedFromResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if result.Details == nil { + t.Errorf("Details = nil, want %v", tt.expectedDetail) + } else if *result.Details != tt.expectedDetail { + t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail) + } + }) + } +} + +func TestCalculateXAlignedFromScore(t *testing.T) { + tests := []struct { + name string + result *model.AuthResult + expectedScore int + }{ + { + name: "pass result gives no penalty", + result: &model.AuthResult{ + Result: model.AuthResultResultPass, + }, + expectedScore: 0, + }, + { + name: "fail result gives full penalty", + result: &model.AuthResult{ + Result: model.AuthResultResultFail, + }, + expectedScore: -100, + }, + { + name: "neutral result gives zero score", + result: &model.AuthResult{ + Result: model.AuthResultResultNeutral, + }, + expectedScore: 0, + }, + { + name: "none result gives zero score", + result: &model.AuthResult{ + Result: model.AuthResultResultNone, + }, + expectedScore: 0, + }, + { + name: "nil result gives zero score", + result: nil, + expectedScore: 0, + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &model.AuthenticationResults{ + XAlignedFrom: tt.result, + } + + score := analyzer.calculateXAlignedFromScore(results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go new file mode 100644 index 0000000..b33279e --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -0,0 +1,74 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results +// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-google-dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case model.AuthResultResultPass: + // pass: don't alter the score + default: // fail + return -100 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go new file mode 100644 index 0000000..4013340 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -0,0 +1,83 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult model.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "x-google-dkim pass with domain", + part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", + expectedResult: model.AuthResultResultPass, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: model.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: model.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: model.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +} diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index ac46259..06f8ddf 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -27,18 +27,22 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "time" "unicode" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" "golang.org/x/net/html" ) // ContentAnalyzer analyzes email content (HTML, links, images) type ContentAnalyzer struct { - Timeout time.Duration - httpClient *http.Client + Timeout time.Duration + httpClient *http.Client + listUnsubscribeURLs []string // URLs from List-Unsubscribe header + hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click } // NewContentAnalyzer creates a new content analyzer with configurable timeout @@ -63,6 +67,7 @@ func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { // ContentResults represents content analysis results type ContentResults struct { + IsMultipart bool HTMLValid bool HTMLErrors []string Links []LinkCheck @@ -75,6 +80,12 @@ type ContentResults struct { ImageTextRatio float32 // Ratio of images to text SuspiciousURLs []string ContentIssues []string + HarmfullIssues []string +} + +// HasPlaintext returns true if the email has plain text content +func (r *ContentResults) HasPlaintext() bool { + return r.TextContent != "" } // LinkCheck represents a link validation result @@ -101,6 +112,15 @@ type ImageCheck struct { func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { results := &ContentResults{} + results.IsMultipart = len(email.Parts) > 1 + + // Parse List-Unsubscribe header URLs for use in link detection + c.listUnsubscribeURLs = email.GetListUnsubscribeURLs() + + // Check for one-click unsubscribe support + listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post") + c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click") + // Get HTML and text parts htmlParts := email.GetHTMLParts() textParts := email.GetTextParts() @@ -117,16 +137,57 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { for _, part := range textParts { results.TextContent += part.Content } + // Extract and validate links from plain text + c.analyzeTextLinks(results.TextContent, results) } // Check plain text/HTML consistency if len(htmlParts) > 0 && len(textParts) > 0 { results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) + } else if !results.IsMultipart { + results.TextPlainRatio = 1.0 } return results } +// analyzeTextLinks extracts and validates URLs from plain text +func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) { + // Regular expression to match URLs in plain text + // Matches http://, https://, and www. URLs + urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`) + + matches := urlRegex.FindAllString(textContent, -1) + + for _, match := range matches { + // Normalize URL (add http:// if missing) + urlStr := match + if strings.HasPrefix(strings.ToLower(urlStr), "www.") { + urlStr = "http://" + urlStr + } + + // Check if this URL already exists in results.Links (from HTML analysis) + exists := false + for _, link := range results.Links { + if link.URL == urlStr { + exists = true + break + } + } + + // Only validate if not already checked + if !exists { + linkCheck := c.validateLink(urlStr) + results.Links = append(results.Links, linkCheck) + + // Check for suspicious URLs + if !linkCheck.IsSafe { + results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr) + } + } + } +} + // analyzeHTML parses and analyzes HTML content func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { results.HTMLContent = htmlContent @@ -170,6 +231,18 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { // Validate link linkCheck := c.validateLink(href) + + // Check for domain misalignment (phishing detection) + linkText := c.getNodeText(n) + if c.hasDomainMisalignment(href, linkText) { + linkCheck.IsSafe = false + if linkCheck.Warning == "" { + linkCheck.Warning = "Link text domain does not match actual URL domain (possible phishing)" + } else { + linkCheck.Warning += "; Link text domain does not match actual URL domain (possible phishing)" + } + } + results.Links = append(results.Links, linkCheck) // Check for suspicious URLs @@ -195,6 +268,59 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { } results.Images = append(results.Images, imageCheck) + + case "script": + // JavaScript in emails is a security risk and typically blocked + results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous

More

", - expectedText: "TextMore", + expectedText: "Text More", }, { name: "With style tag", html: "

Text

More

", - expectedText: "TextMore", + expectedText: "Text More", }, { name: "Empty HTML", @@ -145,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) { linkText: "Read more", expected: false, }, + // Multilingual keyword detection - URL path + { + name: "German abmelden in URL", + href: "https://example.com/abmelden?id=42", + linkText: "Click here", + expected: true, + }, + { + name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)", + href: "https://example.com/se-desabonner?id=42", + linkText: "Click here", + expected: false, + }, + // Multilingual keyword detection - link text + { + name: "German Abmelden in link text", + href: "https://example.com/manage?id=42&lang=de", + linkText: "Abmelden", + expected: true, + }, + { + name: "French Se désabonner in link text", + href: "https://example.com/manage?id=42&lang=fr", + linkText: "Se désabonner", + expected: true, + }, + { + name: "Russian Отписаться in link text", + href: "https://example.com/manage?id=42&lang=ru", + linkText: "Отписаться", + expected: true, + }, + { + name: "Chinese 退订 in link text", + href: "https://example.com/manage?id=42&lang=zh", + linkText: "退订", + expected: true, + }, + { + name: "Japanese 登録を取り消す in link text", + href: "https://example.com/manage?id=42&lang=ja", + linkText: "登録を取り消す", + expected: true, + }, + { + name: "Korean 구독 해지 in link text", + href: "https://example.com/manage?id=42&lang=ko", + linkText: "구독 해지", + expected: true, + }, + { + name: "Dutch Uitschrijven in link text", + href: "https://example.com/manage?id=42&lang=nl", + linkText: "Uitschrijven", + expected: true, + }, + { + name: "Polish Odsubskrybuj in link text", + href: "https://example.com/manage?id=42&lang=pl", + linkText: "Odsubskrybuj", + expected: true, + }, + { + name: "Turkish Üyeliği sonlandır in link text", + href: "https://example.com/manage?id=42&lang=tr", + linkText: "Üyeliği sonlandır", + expected: true, + }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -214,6 +281,16 @@ func TestIsSuspiciousURL(t *testing.T) { url: "https://mail.example.com/page", expected: false, }, + { + name: "Mailto with @ symbol", + url: "mailto:support@example.com", + expected: false, + }, + { + name: "Mailto with multiple @ symbols", + url: "mailto:user@subdomain@example.com", + expected: false, + }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -608,453 +685,6 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) { } } -func TestGenerateHTMLValidityCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid HTML", - results: &ContentResults{ - HTMLValid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.2, - }, - { - name: "Invalid HTML", - results: &ContentResults{ - HTMLValid: false, - HTMLErrors: []string{"Parse error"}, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateHTMLValidityCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateLinkChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "All links valid", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Status: 200}, - {URL: "https://example.org", Valid: true, Status: 200}, - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, - }, - { - name: "Broken links", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "Links with warnings", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Warning: "Could not verify"}, - }, - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.3, - }, - { - name: "No links", - results: &ContentResults{}, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.generateLinkChecks(tt.results) - - if tt.name == "No links" { - if len(checks) != 0 { - t.Errorf("Expected no checks, got %d", len(checks)) - } - return - } - - if len(checks) == 0 { - t.Fatal("Expected at least one check") - } - - check := checks[0] - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - }) - } -} - -func TestGenerateImageChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "All images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, - {Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"}, - }, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: false}, - {Src: "img2.jpg", HasAlt: false}, - }, - }, - expectedStatus: api.CheckStatusFail, - }, - { - name: "Some images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, - {Src: "img2.jpg", HasAlt: false}, - }, - }, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.generateImageChecks(tt.results) - - if len(checks) == 0 { - t.Fatal("Expected at least one check") - } - - check := checks[0] - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateUnsubscribeCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "Has unsubscribe link", - results: &ContentResults{ - HasUnsubscribe: true, - UnsubscribeLinks: []string{"https://example.com/unsubscribe"}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No unsubscribe link", - results: &ContentResults{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateUnsubscribeCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateTextConsistencyCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "High consistency", - results: &ContentResults{ - TextPlainRatio: 0.8, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "Low consistency", - results: &ContentResults{ - TextPlainRatio: 0.1, - }, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateTextConsistencyCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateImageRatioCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "Reasonable ratio", - results: &ContentResults{ - ImageTextRatio: 3.0, - Images: []ImageCheck{{}, {}, {}}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "High ratio", - results: &ContentResults{ - ImageTextRatio: 7.0, - Images: make([]ImageCheck, 7), - }, - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Excessive ratio", - results: &ContentResults{ - ImageTextRatio: 15.0, - Images: make([]ImageCheck, 15), - }, - expectedStatus: api.CheckStatusFail, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateImageRatioCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateSuspiciousURLCheck(t *testing.T) { - results := &ContentResults{ - SuspiciousURLs: []string{ - "https://bit.ly/abc123", - "https://192.168.1.1/page", - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - check := analyzer.generateSuspiciousURLCheck(results) - - if check.Status != api.CheckStatusWarn { - t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - if !strings.Contains(check.Message, "2") { - t.Error("Message should mention the count of suspicious URLs") - } -} - -func TestGetContentScore(t *testing.T) { - tests := []struct { - name string - results *ContentResults - minScore float32 - maxScore float32 - }{ - { - name: "Nil results", - results: nil, - minScore: 0.0, - maxScore: 0.0, - }, - { - name: "Perfect content", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextPlainRatio: 0.8, - ImageTextRatio: 3.0, - }, - minScore: 1.8, - maxScore: 2.0, - }, - { - name: "Poor content", - results: &ContentResults{ - HTMLValid: false, - Links: []LinkCheck{{Valid: true, Status: 404}}, - Images: []ImageCheck{{HasAlt: false}}, - HasUnsubscribe: false, - TextPlainRatio: 0.1, - ImageTextRatio: 15.0, - SuspiciousURLs: []string{"url1", "url2"}, - }, - minScore: 0.0, - maxScore: 0.5, - }, - { - name: "Average content", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: false, - TextPlainRatio: 0.5, - ImageTextRatio: 4.0, - }, - minScore: 1.0, - maxScore: 1.8, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := analyzer.GetContentScore(tt.results) - - if score < tt.minScore || score > tt.maxScore { - t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - - // Ensure score is capped at 2.0 - if score > 2.0 { - t.Errorf("Score %v exceeds maximum of 2.0", score) - } - - // Ensure score is not negative - if score < 0.0 { - t.Errorf("Score %v is negative", score) - } - }) - } -} - -func TestGenerateContentChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "Complete results", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextContent: "Plain text", - HTMLContent: "

HTML text

", - ImageTextRatio: 3.0, - }, - minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio - }, - { - name: "With suspicious URLs", - results: &ContentResults{ - HTMLValid: true, - SuspiciousURLs: []string{"url1"}, - }, - minChecks: 3, // HTML, Unsubscribe, Suspicious URLs - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateContentChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Content category - for _, check := range checks { - if check.Category != api.Content { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content) - } - } - }) - } -} - // Helper functions for testing func parseHTML(htmlStr string) (*html.Node, error) { @@ -1076,3 +706,276 @@ func findFirstLink(n *html.Node) *html.Node { func parseURL(urlStr string) (*url.URL, error) { return url.Parse(urlStr) } + +func TestHasDomainMisalignment(t *testing.T) { + tests := []struct { + name string + href string + linkText string + expected bool + reason string + }{ + // Phishing cases - should return true + { + name: "Obvious phishing - different domains", + href: "https://evil.com/page", + linkText: "Click here to verify your paypal.com account", + expected: true, + reason: "Link text shows 'paypal.com' but URL points to 'evil.com'", + }, + { + name: "Domain in link text differs from URL", + href: "http://attacker.net", + linkText: "Visit google.com for more info", + expected: true, + reason: "Link text shows 'google.com' but URL points to 'attacker.net'", + }, + { + name: "URL shown in text differs from actual URL", + href: "https://phishing-site.xyz/login", + linkText: "https://www.bank.example.com/secure", + expected: true, + reason: "Full URL in text doesn't match actual destination", + }, + { + name: "Similar but different domain", + href: "https://paypa1.com/login", + linkText: "Login to your paypal.com account", + expected: true, + reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'", + }, + { + name: "Subdomain spoofing", + href: "https://paypal.com.evil.com/login", + linkText: "Verify your paypal.com account", + expected: true, + reason: "Domain is 'evil.com', not 'paypal.com'", + }, + { + name: "Multiple domains in text, none match", + href: "https://badsite.com", + linkText: "Transfer from bank.com to paypal.com", + expected: true, + reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'", + }, + + // Legitimate cases - should return false + { + name: "Exact domain match", + href: "https://example.com/page", + linkText: "Visit example.com for more information", + expected: false, + reason: "Domains match exactly", + }, + { + name: "Legitimate subdomain", + href: "https://mail.google.com/inbox", + linkText: "Check your google.com email", + expected: false, + reason: "Subdomain of the mentioned domain", + }, + { + name: "www prefix variation", + href: "https://www.example.com/page", + linkText: "Visit example.com", + expected: false, + reason: "www prefix is acceptable variation", + }, + { + name: "Generic link text - click here", + href: "https://anywhere.com", + linkText: "click here", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - read more", + href: "https://example.com/article", + linkText: "Read more", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - learn more", + href: "https://example.com/info", + linkText: "Learn More", + expected: false, + reason: "Generic text doesn't contain a domain (case insensitive)", + }, + { + name: "No domain in link text", + href: "https://example.com/page", + linkText: "Click to continue", + expected: false, + reason: "Link text has no domain reference", + }, + { + name: "Short link text", + href: "https://example.com", + linkText: "Go", + expected: false, + reason: "Text too short to contain meaningful domain", + }, + { + name: "Empty link text", + href: "https://example.com", + linkText: "", + expected: false, + reason: "Empty text cannot contain domain", + }, + { + name: "Mailto link - matching domain", + href: "mailto:support@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Mailto email matches text email", + }, + { + name: "Mailto link - domain mismatch (phishing)", + href: "mailto:attacker@evil.com", + linkText: "Contact support@paypal.com for help", + expected: true, + reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'", + }, + { + name: "Mailto link - generic text", + href: "mailto:info@example.com", + linkText: "Contact us", + expected: false, + reason: "Generic text without domain reference", + }, + { + name: "Mailto link - same domain different user", + href: "mailto:sales@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Both emails share the same domain", + }, + { + name: "Mailto link - text shows only domain", + href: "mailto:info@example.com", + linkText: "Write to example.com", + expected: false, + reason: "Text domain matches mailto domain", + }, + { + name: "Mailto link - domain in text doesn't match", + href: "mailto:scam@phishing.net", + linkText: "Reply to customer-service@amazon.com", + expected: true, + reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text", + }, + { + name: "Tel link", + href: "tel:+1234567890", + linkText: "Call example.com support", + expected: false, + reason: "Non-HTTP(S) links are excluded", + }, + { + name: "Same base domain with different subdomains", + href: "https://www.example.com/page", + linkText: "Visit blog.example.com", + expected: false, + reason: "Both share same base domain 'example.com'", + }, + { + name: "URL with path matches domain in text", + href: "https://example.com/section/page", + linkText: "Go to example.com", + expected: false, + reason: "Domain matches, path doesn't matter", + }, + { + name: "Generic text - subscribe", + href: "https://newsletter.example.com/signup", + linkText: "Subscribe", + expected: false, + reason: "Generic call-to-action text", + }, + { + name: "Generic text - unsubscribe", + href: "https://example.com/unsubscribe?id=123", + linkText: "Unsubscribe", + expected: false, + reason: "Generic unsubscribe text", + }, + { + name: "Generic text - download", + href: "https://files.example.com/document.pdf", + linkText: "Download", + expected: false, + reason: "Generic action text", + }, + { + name: "Descriptive text without domain", + href: "https://shop.example.com/products", + linkText: "View our latest products", + expected: false, + reason: "No domain mentioned in text", + }, + + // Edge cases + { + name: "Domain-like text but not valid domain", + href: "https://example.com", + linkText: "Save up to 50.00 dollars", + expected: false, + reason: "50.00 looks like domain but isn't", + }, + { + name: "Text with http prefix matching domain", + href: "https://example.com/page", + linkText: "Visit http://example.com", + expected: false, + reason: "Domains match despite different protocols in display", + }, + { + name: "Port in URL should not affect matching", + href: "https://example.com:8080/page", + linkText: "Go to example.com", + expected: false, + reason: "Port number doesn't affect domain matching", + }, + { + name: "Whitespace in link text", + href: "https://example.com", + linkText: " example.com ", + expected: false, + reason: "Whitespace should be trimmed", + }, + { + name: "Multiple spaces in generic text", + href: "https://example.com", + linkText: "click here", + expected: false, + reason: "Generic text with extra spaces", + }, + { + name: "Anchor fragment in URL", + href: "https://example.com/page#section", + linkText: "example.com section", + expected: false, + reason: "Fragment doesn't affect domain matching", + }, + { + name: "Query parameters in URL", + href: "https://example.com/page?utm_source=email", + linkText: "Visit example.com", + expected: false, + reason: "Query params don't affect domain matching", + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.hasDomainMisalignment(tt.href, tt.linkText) + if result != tt.expected { + t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s", + tt.href, tt.linkText, result, tt.expected, tt.reason) + } + }) + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 9a6d26f..6bc7c39 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -22,698 +22,215 @@ package analyzer import ( - "context" - "fmt" - "net" - "regexp" - "strings" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // DNSAnalyzer analyzes DNS records for email domains type DNSAnalyzer struct { Timeout time.Duration - resolver *net.Resolver + resolver DNSResolver } // NewDNSAnalyzer creates a new DNS analyzer with configurable timeout func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { + return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver()) +} + +// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver. +// If resolver is nil, a StandardDNSResolver will be used. +func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer { if timeout == 0 { timeout = 10 * time.Second // Default timeout } - return &DNSAnalyzer{ - Timeout: timeout, - resolver: &net.Resolver{ - PreferGo: true, - }, + if resolver == nil { + resolver = NewStandardDNSResolver() + } + return &DNSAnalyzer{ + Timeout: timeout, + resolver: resolver, } -} - -// DNSResults represents DNS validation results for an email -type DNSResults struct { - Domain string - MXRecords []MXRecord - SPFRecord *SPFRecord - DKIMRecords []DKIMRecord - DMARCRecord *DMARCRecord - BIMIRecord *BIMIRecord - Errors []string -} - -// MXRecord represents an MX record -type MXRecord struct { - Host string - Priority uint16 - Valid bool - Error string -} - -// SPFRecord represents an SPF record -type SPFRecord struct { - Record string - Valid bool - Error string -} - -// DKIMRecord represents a DKIM record -type DKIMRecord struct { - Selector string - Domain string - Record string - Valid bool - Error string -} - -// DMARCRecord represents a DMARC record -type DMARCRecord struct { - Record string - Policy string // none, quarantine, reject - Valid bool - Error string -} - -// BIMIRecord represents a BIMI record -type BIMIRecord struct { - Selector string - Domain string - Record string - LogoURL string // URL to the brand logo (SVG) - VMCURL string // URL to Verified Mark Certificate (optional) - Valid bool - Error string } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults { // Extract domain from From address - domain := d.extractDomain(email) - if domain == "" { - return &DNSResults{ - Errors: []string{"Unable to extract domain from email"}, + if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { + return &model.DNSResults{ + Errors: &[]string{"Unable to extract domain from email"}, } } + fromDomain := *headersResults.DomainAlignment.FromDomain - results := &DNSResults{ - Domain: domain, + results := &model.DNSResults{ + FromDomain: fromDomain, + RpDomain: headersResults.DomainAlignment.ReturnPathDomain, } - // Check MX records - results.MXRecords = d.checkMXRecords(domain) + // Determine which domain to check SPF for (Return-Path domain) + // SPF validates the envelope sender (Return-Path), not the From header + spfDomain := fromDomain + if results.RpDomain != nil { + spfDomain = *results.RpDomain + } - // Check SPF record - results.SPFRecord = d.checkSPFRecord(domain) - - // Check DKIM records (from authentication results) - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) - } + // Store sender IP for later use in scoring + var senderIP string + if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 { + firstHop := (*headersResults.ReceivedChain)[0] + if firstHop.Ip != nil && *firstHop.Ip != "" { + senderIP = *firstHop.Ip + ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP) + if len(ptrRecords) > 0 { + results.PtrRecords = &ptrRecords + } + if len(forwardRecords) > 0 { + results.PtrForwardRecords = &forwardRecords } } } + // Check MX records for From domain (where replies would go) + results.FromMxRecords = d.checkMXRecords(fromDomain) + + // Check MX records for Return-Path domain (where bounces would go) + // Only check if Return-Path domain is different from From domain + if results.RpDomain != nil && *results.RpDomain != fromDomain { + results.RpMxRecords = d.checkMXRecords(*results.RpDomain) + } + + // Check SPF records (for Return-Path domain - this is the envelope sender) + // SPF validates the MAIL FROM command, which corresponds to Return-Path + results.SpfRecords = d.checkSPFRecords(spfDomain) + + // Check DKIM records by parsing DKIM-Signature headers directly + for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { + dkimRecord := d.checkDKIMRecord(sig) + if dkimRecord != nil { + if results.DkimRecords == nil { + results.DkimRecords = new([]model.DKIMRecord) + } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) + } + } + + // Check DMARC record (for From domain - DMARC protects the visible sender) + // DMARC validates alignment between SPF/DKIM and the From domain + results.DmarcRecord = d.checkDMARCRecord(fromDomain) + + // Check BIMI record (for From domain - branding is based on visible sender) + results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") + + return results +} + +// AnalyzeDomainOnly performs DNS validation for a domain without email context +// This is useful for checking domain configuration without sending an actual email +func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { + results := &model.DNSResults{ + FromDomain: domain, + } + + // Check MX records + results.FromMxRecords = d.checkMXRecords(domain) + + // Check SPF records + results.SpfRecords = d.checkSPFRecords(domain) + // Check DMARC record - results.DMARCRecord = d.checkDMARCRecord(domain) + results.DmarcRecord = d.checkDMARCRecord(domain) - // Check BIMI record (using default selector) - results.BIMIRecord = d.checkBIMIRecord(domain, "default") + // Check BIMI record with default selector + results.BimiRecord = d.checkBIMIRecord(domain, "default") return results } -// extractDomain extracts the domain from the email's From address -func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { - if email.From != nil && email.From.Address != "" { - parts := strings.Split(email.From.Address, "@") - if len(parts) == 2 { - return strings.ToLower(strings.TrimSpace(parts[1])) - } - } - return "" -} - -// checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - mxRecords, err := d.resolver.LookupMX(ctx, domain) - if err != nil { - return []MXRecord{ - { - Valid: false, - Error: fmt.Sprintf("Failed to lookup MX records: %v", err), - }, - } - } - - if len(mxRecords) == 0 { - return []MXRecord{ - { - Valid: false, - Error: "No MX records found", - }, - } - } - - var results []MXRecord - for _, mx := range mxRecords { - results = append(results, MXRecord{ - Host: mx.Host, - Priority: mx.Pref, - Valid: true, - }) - } - - return results -} - -// checkSPFRecord looks up and validates SPF record for a domain -func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, domain) - if err != nil { - return &SPFRecord{ - Valid: false, - Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), - } - } - - // Find SPF record (starts with "v=spf1") - var spfRecord string - spfCount := 0 - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=spf1") { - spfRecord = txt - spfCount++ - } - } - - if spfCount == 0 { - return &SPFRecord{ - Valid: false, - Error: "No SPF record found", - } - } - - if spfCount > 1 { - return &SPFRecord{ - Record: spfRecord, - Valid: false, - Error: "Multiple SPF records found (RFC violation)", - } - } - - // Basic validation - if !d.validateSPF(spfRecord) { - return &SPFRecord{ - Record: spfRecord, - Valid: false, - Error: "SPF record appears malformed", - } - } - - return &SPFRecord{ - Record: spfRecord, - Valid: true, - } -} - -// validateSPF performs basic SPF record validation -func (d *DNSAnalyzer) validateSPF(record string) bool { - // Must start with v=spf1 - if !strings.HasPrefix(record, "v=spf1") { - return false - } - - // Check for common syntax issues - // Should have a final mechanism (all, +all, -all, ~all, ?all) - validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} - hasValidEnding := false - for _, ending := range validEndings { - if strings.HasSuffix(record, ending) { - hasValidEnding = true - break - } - } - - return hasValidEnding -} - -// checkDKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { - // DKIM records are at: selector._domainkey.domain - dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) - if err != nil { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), - } - } - - if len(txtRecords) == 0 { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: "No DKIM record found", - } - } - - // Concatenate all TXT record parts (DKIM can be split) - dkimRecord := strings.Join(txtRecords, "") - - // Basic validation - should contain "v=DKIM1" and "p=" (public key) - if !d.validateDKIM(dkimRecord) { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Record: dkimRecord, - Valid: false, - Error: "DKIM record appears malformed", - } - } - - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Record: dkimRecord, - Valid: true, - } -} - -// validateDKIM performs basic DKIM record validation -func (d *DNSAnalyzer) validateDKIM(record string) bool { - // Should contain p= tag (public key) - if !strings.Contains(record, "p=") { - return false - } - - // Often contains v=DKIM1 but not required - // If v= is present, it should be DKIM1 - if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { - return false - } - - return true -} - -// checkDMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { - // DMARC records are at: _dmarc.domain - dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) - if err != nil { - return &DMARCRecord{ - Valid: false, - Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), - } - } - - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break - } - } - - if dmarcRecord == "" { - return &DMARCRecord{ - Valid: false, - Error: "No DMARC record found", - } - } - - // Extract policy - policy := d.extractDMARCPolicy(dmarcRecord) - - // Basic validation - if !d.validateDMARC(dmarcRecord) { - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, - Valid: false, - Error: "DMARC record appears malformed", - } - } - - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, - Valid: true, - } -} - -// extractDMARCPolicy extracts the policy from a DMARC record -func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { - // Look for p=none, p=quarantine, or p=reject - re := regexp.MustCompile(`p=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "unknown" -} - -// validateDMARC performs basic DMARC record validation -func (d *DNSAnalyzer) validateDMARC(record string) bool { - // Must start with v=DMARC1 - if !strings.HasPrefix(record, "v=DMARC1") { - return false - } - - // Must have a policy tag - if !strings.Contains(record, "p=") { - return false - } - - return true -} - -// checkBIMIRecord looks up and validates BIMI record for a domain and selector -func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { - // BIMI records are at: selector._bimi.domain - bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) - if err != nil { - return &BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), - } - } - - if len(txtRecords) == 0 { - return &BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: "No BIMI record found", - } - } - - // Concatenate all TXT record parts (BIMI can be split) - bimiRecord := strings.Join(txtRecords, "") - - // Extract logo URL and VMC URL - logoURL := d.extractBIMITag(bimiRecord, "l") - vmcURL := d.extractBIMITag(bimiRecord, "a") - - // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) - if !d.validateBIMI(bimiRecord) { - return &BIMIRecord{ - Selector: selector, - Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, - Valid: false, - Error: "BIMI record appears malformed", - } - } - - return &BIMIRecord{ - Selector: selector, - Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, - Valid: true, - } -} - -// extractBIMITag extracts a tag value from a BIMI record -func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { - // Look for tag=value pattern - re := regexp.MustCompile(tag + `=([^;]+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return strings.TrimSpace(matches[1]) - } - return "" -} - -// validateBIMI performs basic BIMI record validation -func (d *DNSAnalyzer) validateBIMI(record string) bool { - // Must start with v=BIMI1 - if !strings.HasPrefix(record, "v=BIMI1") { - return false - } - - // Must have a logo URL tag (l=) - if !strings.Contains(record, "l=") { - return false - } - - return true -} - -// GenerateDNSChecks generates check results for DNS validation -func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { - var checks []api.Check - +// CalculateDomainOnlyScore calculates the DNS score for domain-only tests +// Returns a score from 0-100 where higher is better +// This version excludes PTR and DKIM checks since they require email context +func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) { if results == nil { - return checks + return 0, "" } - // MX record check - checks = append(checks, d.generateMXCheck(results)) + score := 0 - // SPF record check - if results.SPFRecord != nil { - checks = append(checks, d.generateSPFCheck(results.SPFRecord)) + // MX Records: 30 points (only one domain to check) + mxScore := d.calculateMXScore(results) + // Since calculateMXScore checks both From and RP domains, + // and we only have From domain, we use the full score + score += 30 * mxScore / 100 + + // SPF Records: 30 points + score += 30 * d.calculateSPFScore(results) / 100 + + // DMARC Record: 40 points + score += 40 * d.calculateDMARCScore(results) / 100 + + // BIMI Record: only bonus + if results.BimiRecord != nil && results.BimiRecord.Valid { + if score >= 100 { + return 100, "A+" + } } - // DKIM record checks - for _, dkim := range results.DKIMRecords { - checks = append(checks, d.generateDKIMCheck(&dkim)) + // Ensure score doesn't exceed maximum + if score > 100 { + score = 100 } - // DMARC record check - if results.DMARCRecord != nil { - checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) + // Ensure score is non-negative + if score < 0 { + score = 0 } - // BIMI record check (optional) - if results.BIMIRecord != nil { - checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) - } - - return checks + return score, ScoreToGradeKind(score) } -// generateMXCheck creates a check for MX records -func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "MX Records", +// CalculateDNSScore calculates the DNS score from records results +// Returns a score from 0-100 where higher is better +// senderIP is the original sender IP address used for FCrDNS verification +func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) { + if results == nil { + return 0, "" } - if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityCritical) + score := 0 - if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { - check.Message = results.MXRecords[0].Error - } else { - check.Message = "No valid MX records found" - } - check.Advice = api.PtrTo("Configure MX records for your domain to receive email") - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) + // PTR and Forward DNS: 20 points + score += 20 * d.calculatePTRScore(results, senderIP) / 100 - // Add details about MX records - var mxList []string - for _, mx := range results.MXRecords { - mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority)) + // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) + score += 20 * d.calculateMXScore(results) / 100 + + // SPF Records: 20 points + score += 20 * d.calculateSPFScore(results) / 100 + + // DKIM Records: 20 points + score += 20 * d.calculateDKIMScore(results) / 100 + + // DMARC Record: 20 points + score += 20 * d.calculateDMARCScore(results) / 100 + + // BIMI Record + // BIMI is optional but indicates advanced email branding + if results.BimiRecord != nil && results.BimiRecord.Valid { + if score >= 100 { + return 100, "A+" } - details := strings.Join(mxList, ", ") - check.Details = &details - check.Advice = api.PtrTo("Your MX records are properly configured") } - return check -} - -// generateSPFCheck creates a check for SPF records -func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "SPF Record", - } - - if !spf.Valid { - // If no record exists at all, it's a failure - if spf.Record == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = spf.Error - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") - } else { - // If record exists but is invalid, it's a warning - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Review and fix your SPF record syntax") - check.Details = &spf.Record - } - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "Valid SPF record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Details = &spf.Record - check.Advice = api.PtrTo("Your SPF record is properly configured") - } - - return check -} - -// generateDKIMCheck creates a check for DKIM records -func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector), - } - - if !dkim.Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "Valid DKIM record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - check.Advice = api.PtrTo("Your DKIM record is properly published") - } - - return check -} - -// generateDMARCCheck creates a check for DMARC records -func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "DMARC Record", - } - - if !dmarc.Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = dmarc.Error - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Details = &dmarc.Record - - // Provide advice based on policy - switch dmarc.Policy { - case "none": - advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection" - check.Advice = &advice - case "quarantine": - advice := "DMARC policy is set to 'quarantine'. This provides good protection" - check.Advice = &advice - case "reject": - advice := "DMARC policy is set to 'reject'. This provides the strongest protection" - check.Advice = &advice - default: - advice := "Your DMARC record is properly configured" - check.Advice = &advice - } - } - - return check -} - -// generateBIMICheck creates a check for BIMI records -func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "BIMI Record", - } - - if !bimi.Valid { - // BIMI is optional, so missing record is just informational - if bimi.Record == "" { - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = "No BIMI record found (optional)" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") - } else { - // If record exists but is invalid - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") - check.Details = &bimi.Record - } - } else { - check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) - check.Message = "Valid BIMI record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - - // Build details with logo and VMC URLs - var detailsParts []string - detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) - if bimi.LogoURL != "" { - detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) - } - if bimi.VMCURL != "" { - detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) - check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") - } else { - check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") - } - - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check + // Ensure score doesn't exceed maximum + if score > 100 { + score = 100 + } + + // Ensure score is non-negative + if score < 0 { + score = 0 + } + + return score, ScoreToGrade(score) } diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go new file mode 100644 index 0000000..223bfdc --- /dev/null +++ b/pkg/analyzer/dns_bimi.go @@ -0,0 +1,115 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &model.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &model.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: utils.PtrTo("No BIMI record found"), + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &model.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: false, + Error: utils.PtrTo("BIMI record appears malformed"), + } + } + + return &model.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} diff --git a/pkg/analyzer/dns_bimi_test.go b/pkg/analyzer/dns_bimi_test.go new file mode 100644 index 0000000..cf7df83 --- /dev/null +++ b/pkg/analyzer/dns_bimi_test.go @@ -0,0 +1,128 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go new file mode 100644 index 0000000..115e347 --- /dev/null +++ b/pkg/analyzer/dns_dkim.go @@ -0,0 +1,260 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "crypto/x509" + "encoding/base64" + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header. +type DKIMHeader struct { + Domain string + Selector string + Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256) +} + +// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values. +func parseDKIMSignatures(signatures []string) []DKIMHeader { + var results []DKIMHeader + for _, sig := range signatures { + var domain, selector, algorithm string + for _, part := range strings.Split(sig, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "d": + domain = val + case "s": + selector = val + case "a": + algorithm = val + } + } + if domain != "" && selector != "" { + results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm}) + } + } + return results +} + +// parseDKIMTags splits a DKIM DNS record into a tag→value map. +func parseDKIMTags(record string) map[string]string { + tags := make(map[string]string) + for _, part := range strings.Split(record, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + return tags +} + +// parseKeySize derives the public key bit length from a base64-encoded DER public key. +// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256. +func parseKeySize(keyType, p string) *int { + switch strings.ToLower(keyType) { + case "ed25519": + return utils.PtrTo(256) + case "rsa", "": + der, err := base64.StdEncoding.DecodeString(p) + if err != nil { + // Try without padding + der, err = base64.RawStdEncoding.DecodeString(p) + if err != nil { + return nil + } + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil + } + if rsaPub, ok := pub.(interface{ Size() int }); ok { + bits := rsaPub.Size() * 8 + return &bits + } + return nil + } + return nil +} + +// checkDKIMRecord looks up and validates DKIM record for a domain and selector. +func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord { + dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) + if err != nil { + return &model.DKIMRecord{ + Selector: h.Selector, + Domain: h.Domain, + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &model.DKIMRecord{ + Selector: h.Selector, + Domain: h.Domain, + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + Valid: false, + Error: utils.PtrTo("No DKIM record found"), + } + } + + // Concatenate all TXT record parts (DKIM can be split) + dkimRecord := strings.Join(txtRecords, "") + + if !d.validateDKIM(dkimRecord) { + return &model.DKIMRecord{ + Selector: h.Selector, + Domain: h.Domain, + Record: utils.PtrTo(dkimRecord), + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + Valid: false, + Error: utils.PtrTo("DKIM record appears malformed"), + } + } + + tags := parseDKIMTags(dkimRecord) + + keyType := tags["k"] + if keyType == "" { + keyType = "rsa" // RFC 6376 default + } + + var hashAlgorithms []string + if h, ok := tags["h"]; ok && h != "" { + for _, alg := range strings.Split(h, ":") { + if a := strings.TrimSpace(alg); a != "" { + hashAlgorithms = append(hashAlgorithms, a) + } + } + } + if hashAlgorithms == nil { + hashAlgorithms = []string{} + } + + return &model.DKIMRecord{ + Selector: h.Selector, + Domain: h.Domain, + Record: &dkimRecord, + KeyType: utils.PtrTo(keyType), + HashAlgorithms: &hashAlgorithms, + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + KeySize: parseKeySize(keyType, tags["p"]), + Valid: true, + } +} + +func signingAlgorithmPtr(a string) *string { + if a == "" { + return nil + } + return &a +} + +// validateDKIM performs basic DKIM record validation. +func (d *DNSAnalyzer) validateDKIM(record string) bool { + if !strings.Contains(record, "p=") { + return false + } + + // If v= is present, it must be DKIM1 + if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { + if results.DkimRecords == nil || len(*results.DkimRecords) == 0 { + return 0 + } + + hasValid := false + for _, dkim := range *results.DkimRecords { + if dkim.Valid { + hasValid = true + break + } + } + + if !hasValid { + return 25 + } + + score = 100 + + // Apply security penalties on the best valid record + for _, dkim := range *results.DkimRecords { + if !dkim.Valid { + continue + } + + // SHA-1 signing is deprecated (RFC 8301) + if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") { + if score > 60 { + score = 60 + } + } + + // Key size penalties apply only to RSA + keyType := "" + if dkim.KeyType != nil { + keyType = strings.ToLower(*dkim.KeyType) + } + if keyType == "rsa" || keyType == "" { + if dkim.KeySize != nil { + switch { + case *dkim.KeySize < 1024: + if score > 25 { + score = 25 + } + case *dkim.KeySize < 2048: + if score > 75 { + score = 75 + } + } + } + } + // Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty. + } + + return +} diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go new file mode 100644 index 0000000..40e28a5 --- /dev/null +++ b/pkg/analyzer/dns_dkim_test.go @@ -0,0 +1,409 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "testing" + "time" +) + +func TestParseDKIMSignatures(t *testing.T) { + tests := []struct { + name string + signatures []string + expected []DKIMHeader + }{ + { + name: "Empty input", + signatures: nil, + expected: nil, + }, + { + name: "Empty string", + signatures: []string{""}, + expected: nil, + }, + { + name: "Simple Gmail-style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, + }, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}}, + }, + { + name: "Microsoft 365 style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, + }, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + }, + { + name: "Tab-folded multiline (Postfix-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", + }, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}}, + }, + { + name: "Space-folded multiline (RFC-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}}, + }, + { + name: "d= and s= on separate continuation lines", + signatures: []string{ + "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", + }, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + }, + { + name: "No space after semicolons", + signatures: []string{ + `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}}, + }, + { + name: "Multiple spaces after semicolons", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}}, + }, + { + name: "Ed25519 signature (RFC 8463)", + signatures: []string{ + "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}}, + }, + { + name: "Multiple signatures (ESP double-signing)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, + }, + expected: []DKIMHeader{ + {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"}, + {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"}, + }, + }, + { + name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)", + signatures: []string{ + `v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, + }, + expected: []DKIMHeader{ + {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}, + {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}, + }, + }, + { + name: "Amazon SES long selectors", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`, + `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, + }, + expected: []DKIMHeader{ + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"}, + }, + }, + { + name: "Subdomain in d=", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}}, + }, + { + name: "Deeply nested subdomain", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}}, + }, + { + name: "Selector with hyphens (Microsoft 365 custom domain style)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}}, + }, + { + name: "Selector with dots", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}}, + }, + { + name: "Single-character selector", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}}, + }, + { + name: "Postmark-style timestamp selector, s= before d=", + signatures: []string{ + `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, + }, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}}, + }, + { + name: "d= and s= at the very end", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}}, + }, + { + name: "Full tag set", + signatures: []string{ + `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + }, + { + name: "Missing d= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing s= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing both d= and s= tags", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Mix of valid and invalid signatures", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{ + {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"}, + {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDKIMSignatures(tt.signatures) + if len(result) != len(tt.expected) { + t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected) + } + for i := range tt.expected { + if result[i].Domain != tt.expected[i].Domain { + t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain) + } + if result[i].Selector != tt.expected[i].Selector { + t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) + } + if result[i].Algorithm != tt.expected[i].Algorithm { + t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm) + } + } + }) + } +} + +func TestValidateDKIM(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DKIM with version", + record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Valid DKIM without version", + record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Invalid DKIM - no public key", + record: "v=DKIM1; k=rsa", + expected: false, + }, + { + name: "Invalid DKIM - wrong version", + record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: false, + }, + { + name: "Invalid DKIM - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDKIM(tt.record) + if result != tt.expected { + t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestParseDKIMTags(t *testing.T) { + tests := []struct { + name string + record string + wantTags map[string]string + }{ + { + name: "standard RSA record", + record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256", + wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"}, + }, + { + name: "ed25519 record", + record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS", + wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"}, + }, + { + name: "missing k= defaults", + record: "v=DKIM1; p=MIIBI", + wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"}, + }, + { + name: "empty record", + record: "", + wantTags: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseDKIMTags(tt.record) + for key, want := range tt.wantTags { + if got[key] != want { + t.Errorf("tag %q = %q, want %q", key, got[key], want) + } + } + }) + } +} + +func TestParseKeySize(t *testing.T) { + // Generate a real RSA key for testing + rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024) + rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048) + + der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey) + der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey) + + p1024 := base64.StdEncoding.EncodeToString(der1024) + p2048 := base64.StdEncoding.EncodeToString(der2048) + + tests := []struct { + name string + keyType string + p string + want *int + }{ + { + name: "RSA 1024", + keyType: "rsa", + p: p1024, + want: intPtr(1024), + }, + { + name: "RSA 2048", + keyType: "rsa", + p: p2048, + want: intPtr(2048), + }, + { + name: "Ed25519 always 256", + keyType: "ed25519", + p: "11qYAYKxCrfVS", + want: intPtr(256), + }, + { + name: "Unknown key type", + keyType: "unknown", + p: "somedata", + want: nil, + }, + { + name: "Invalid RSA base64", + keyType: "rsa", + p: "!!!not-base64!!!", + want: nil, + }, + { + name: "Empty k= defaults to RSA", + keyType: "", + p: p2048, + want: intPtr(2048), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseKeySize(tt.keyType, tt.p) + if tt.want == nil { + if got != nil { + t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got) + } + return + } + if got == nil { + t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want) + } + if *got != *tt.want { + t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want) + } + }) + } +} + +func intPtr(v int) *int { return &v } diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go new file mode 100644 index 0000000..b89500b --- /dev/null +++ b/pkg/analyzer/dns_dmarc.go @@ -0,0 +1,314 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2} + +// lookupDMARCAt queries _dmarc. and returns the raw DMARC1 TXT record. +// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred. +func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain)) + if lookupErr != nil { + if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound { + return "", true, nil + } + return "", false, lookupErr + } + + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + return txt, false, nil + } + } + return "", true, nil +} + +// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. +func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { + tags := parseDKIMTags(rawRecord) + + // Policy + policy := "unknown" + switch tags["p"] { + case "none", "quarantine", "reject": + policy = tags["p"] + } + + // SPF alignment (default: relaxed) + spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) + if tags["aspf"] == "s" { + spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) + } + + // DKIM alignment (default: relaxed) + dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) + if tags["adkim"] == "s" { + dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) + } + + // Subdomain policy + var subdomainPolicy *model.DMARCRecordSubdomainPolicy + switch tags["sp"] { + case "none", "quarantine", "reject": + subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"])) + } + + // Non-existent subdomain policy (DMARCbis np=) + var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy + switch tags["np"] { + case "none", "quarantine", "reject": + nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"])) + } + + // Percentage (pct=, deprecated in DMARCbis) + var percentage *int + if pctStr, ok := tags["pct"]; ok { + if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 { + percentage = &pct + } + } + + // Test mode (DMARCbis t=) + var testMode *bool + if t, ok := tags["t"]; ok { + v := t == "y" + testMode = &v + } + + // PSD (DMARCbis psd=) + var psd *model.DMARCRecordPsd + switch tags["psd"] { + case "y", "n", "u": + psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"])) + } + + rec := &model.DMARCRecord{ + Domain: &foundDomain, + Record: &rawRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, + Percentage: percentage, + TestMode: testMode, + Psd: psd, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + } + if percentage != nil { + rec.DeprecatedPct = utils.PtrTo(true) + } + if _, ok := tags["rf"]; ok { + rec.DeprecatedRf = utils.PtrTo(true) + } + if _, ok := tags["ri"]; ok { + rec.DeprecatedRi = utils.PtrTo(true) + } + + if !d.validateDMARC(rawRecord) { + rec.Valid = false + rec.Error = utils.PtrTo("DMARC record appears malformed") + return rec + } + + rec.Valid = true + return rec +} + +// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10). +// It queries _dmarc. and walks up the label hierarchy until a valid DMARC +// record is found or all labels are exhausted. Maximum 8 DNS queries per message. +// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label +// suffix before resuming normally (to stay within the 8-query budget). +// Single-label (TLD) records are only accepted when they carry psd=y. +func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) { + labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".") + n := len(labels) + + for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 { + current := strings.Join(labels[i:], ".") + + raw, notFound, lookupErr := d.lookupDMARCAt(current) + if lookupErr != nil { + return "", "", lookupErr + } + if !notFound { + // Single-label (TLD) records are only used when the record explicitly opts in. + if !strings.Contains(current, ".") { + if d.extractDMARCPSDValue(raw) != "y" { + break + } + } + return raw, current, nil + } + + // DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the + // 7-label suffix for the next query rather than stepping one label at a time. + if i == 0 && n >= 8 { + i = n - 8 // the outer i++ will land at n-7 (7 labels from the right) + } + } + + return "", "", nil +} + +// checkDMARCRecord looks up and validates the DMARC record for a domain using +// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the +// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC +// experimental fallback. +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { + raw, foundDomain, err := d.walkDNSForDMARC(domain) + if err != nil { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + if foundDomain == "" { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo("No DMARC record found"), + } + } + return d.parseDMARCRecord(foundDomain, raw) +} + +// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent. +// Used during DNS Tree Walk before full record parsing. +func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string { + v := parseDKIMTags(record)["psd"] + switch v { + case "y", "n", "u": + return v + } + return "" +} + +// validateDMARC performs basic DMARC record validation. +// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid +// rua= but no p= is treated as p=none and considered valid. +func (d *DNSAnalyzer) validateDMARC(record string) bool { + if !strings.HasPrefix(record, "v=DMARC1") { + return false + } + + // p= absent is allowed in DMARCbis when rua= is present (treated as p=none). + if !strings.Contains(record, "p=") { + return strings.Contains(record, "rua=") + } + + return true +} + +func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { + if results.DmarcRecord == nil { + return + } + + if !results.DmarcRecord.Valid { + if results.DmarcRecord.Record != nil { + // Partial credit if a DMARC record exists but has issues + score += 20 + } + return + } + + score += 50 + + // Determine effective policy: DMARCbis t=y downgrades policy one level. + effectivePolicy := "none" + if results.DmarcRecord.Policy != nil { + effectivePolicy = string(*results.DmarcRecord.Policy) + } + testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode + if testMode { + switch effectivePolicy { + case "reject": + effectivePolicy = "quarantine" + case "quarantine": + effectivePolicy = "none" + } + } + + // Bonus/penalty for policy strength + switch effectivePolicy { + case "reject": + score += 25 + case "none": + score -= 25 + } + + // Bonus points for strict alignment modes + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict { + score += 5 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict { + score += 5 + } + + // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] { + score += 15 + } else { + score -= 15 + } + } else { + score += 15 // inherits main policy — good default + } + + // Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker + effectiveSubPolicy := effectivePolicy + if results.DmarcRecord.SubdomainPolicy != nil { + effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy) + } + if results.DmarcRecord.NonexistentSubdomainPolicy == nil { + score += 15 // inherits subdomain/main policy — good default + } else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] { + score += 15 + } else { + score -= 15 + } + + // pct= scaling (deprecated in DMARCbis, kept for backward compatibility). + // pct=0 is an anti-pattern: score it as zero enforcement. + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + score = score * pct / 100 + } + + return +} diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go new file mode 100644 index 0000000..5c34a32 --- /dev/null +++ b/pkg/analyzer/dns_dmarc_test.go @@ -0,0 +1,592 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/utils" +) + +// mockDNSResolver maps domain names to TXT records for testing. +// An entry with value nil means NXDOMAIN; an error value triggers a DNS error. +type mockDNSResolver struct { + txt map[string][]string + err map[string]error +} + +func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) { + if err, ok := m.err[name]; ok { + return nil, err + } + if records, ok := m.txt[name]; ok { + return records, nil + } + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} +} + +func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) { + return nil, nil +} +func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) { + return nil, nil +} +func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer { + if errMap == nil { + errMap = map[string]error{} + } + return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap}) +} + +func TestCheckDMARCRecordFallback(t *testing.T) { + const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + const subRecord = "v=DMARC1; p=reject" + const psdRecord = "v=DMARC1; p=none; psd=y" + + tests := []struct { + name string + domain string + txt map[string][]string + errMap map[string]error + wantValid bool + wantDomain *string + wantErrSubst string + }{ + { + name: "exact domain has DMARC record — no fallback", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.mail.example.com": {subRecord}, + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("mail.example.com"), + }, + { + name: "exact domain NXDOMAIN — tree walk reaches org domain", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.mail.example.com": {"some-other-txt"}, + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.com": {psdRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("com"), + }, + { + name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk", + domain: "mail.example.com", + txt: map[string][]string{ + "_dmarc.com": {"v=DMARC1; p=none"}, + }, + wantValid: false, + wantErrSubst: "No DMARC record found", + }, + { + name: "no record at any level", + domain: "mail.example.com", + txt: map[string][]string{}, + wantValid: false, + wantErrSubst: "No DMARC record found", + }, + { + name: "DNS error on exact domain — error returned", + domain: "mail.example.com", + errMap: map[string]error{ + "_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"), + }, + wantValid: false, + wantErrSubst: "SERVFAIL", + }, + { + name: "domain already at org level — found immediately", + domain: "example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "deep subdomain — tree walk finds record two levels up", + domain: "a.b.example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("example.com"), + }, + { + name: "8-label domain — shortcut to 7-label suffix on miss", + domain: "a.b.c.d.e.f.example.com", + txt: map[string][]string{ + "_dmarc.b.c.d.e.f.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("b.c.d.e.f.example.com"), + }, + { + name: "psd=n record stops tree walk at that level", + domain: "mail.sub.example.com", + txt: map[string][]string{ + "_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"}, + }, + wantValid: true, + wantDomain: utils.PtrTo("sub.example.com"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + analyzer := newMockAnalyzer(tt.txt, tt.errMap) + result := analyzer.checkDMARCRecord(tt.domain) + + if result.Valid != tt.wantValid { + t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid) + } + if tt.wantDomain != nil { + if result.Domain == nil { + t.Fatalf("Domain = nil, want %q", *tt.wantDomain) + } + if *result.Domain != *tt.wantDomain { + t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain) + } + } + if tt.wantErrSubst != "" { + if result.Error == nil { + t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst) + } + if !contains(*result.Error, tt.wantErrSubst) { + t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst) + } + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func TestParseDMARCRecordPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy string + }{ + { + name: "Policy none", + record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + expectedPolicy: "none", + }, + { + name: "Policy quarantine", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPolicy: "quarantine", + }, + { + name: "Policy reject", + record: "v=DMARC1; p=reject; sp=reject", + expectedPolicy: "reject", + }, + { + name: "No policy", + record: "v=DMARC1", + expectedPolicy: "unknown", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if rec.Policy == nil { + t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record) + } + if string(*rec.Policy) != tt.expectedPolicy { + t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy) + } + }) + } +} + +func TestParseDMARCRecordTestMode(t *testing.T) { + tests := []struct { + name string + record string + wantMode *bool + }{ + { + name: "t=y sets test mode", + record: "v=DMARC1; p=reject; t=y", + wantMode: utils.PtrTo(true), + }, + { + name: "t=n explicitly disables test mode", + record: "v=DMARC1; p=reject; t=n", + wantMode: utils.PtrTo(false), + }, + { + name: "absent t tag returns nil", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + wantMode: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode + if tt.wantMode == nil { + if result != nil { + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode) + } + if *result != *tt.wantMode { + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode) + } + } + }) + } +} + +func TestParseDMARCRecordPSD(t *testing.T) { + tests := []struct { + name string + record string + wantPSD *string + }{ + { + name: "psd=y marks Public Suffix Domain", + record: "v=DMARC1; p=none; psd=y", + wantPSD: utils.PtrTo("y"), + }, + { + name: "psd=n marks Org Domain boundary", + record: "v=DMARC1; p=reject; psd=n", + wantPSD: utils.PtrTo("n"), + }, + { + name: "psd=u is explicit unknown", + record: "v=DMARC1; p=quarantine; psd=u", + wantPSD: utils.PtrTo("u"), + }, + { + name: "absent psd tag returns nil", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + wantPSD: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCRecord("example.com", tt.record).Psd + if tt.wantPSD == nil { + if result != nil { + t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD) + } + if string(*result) != *tt.wantPSD { + t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD) + } + } + }) + } +} + +func TestParseDMARCRecordDeprecatedTags(t *testing.T) { + tests := []struct { + name string + record string + wantRf bool + wantRi bool + }{ + {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false}, + {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true}, + {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false}, + {name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false}, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := analyzer.parseDMARCRecord("example.com", tt.record) + gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf + gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi + if gotRf != tt.wantRf { + t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf) + } + if gotRi != tt.wantRi { + t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi) + } + }) + } +} + +func TestValidateDMARC(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DMARC", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Valid DMARC minimal", + record: "v=DMARC1; p=none", + expected: true, + }, + { + name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)", + record: "v=DMARC1; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Invalid DMARC - no version", + record: "p=quarantine", + expected: false, + }, + { + name: "Invalid DMARC - no policy and no rua", + record: "v=DMARC1", + expected: false, + }, + { + name: "Invalid DMARC - wrong version", + record: "v=DMARC2; p=reject", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDMARC(tt.record) + if result != tt.expected { + t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestParseDMARCRecordAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedSPF string + expectedDKIM string + }{ + { + name: "SPF strict, DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedSPF: "strict", + expectedDKIM: "relaxed", + }, + { + name: "SPF relaxed explicit, DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedSPF: "relaxed", + expectedDKIM: "strict", + }, + { + name: "Defaults when neither specified", + record: "v=DMARC1; p=quarantine", + expectedSPF: "relaxed", + expectedDKIM: "relaxed", + }, + { + name: "Both strict in complex record", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedSPF: "strict", + expectedDKIM: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if rec.SpfAlignment == nil { + t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record) + } + if string(*rec.SpfAlignment) != tt.expectedSPF { + t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF) + } + if rec.DkimAlignment == nil { + t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record) + } + if string(*rec.DkimAlignment) != tt.expectedDKIM { + t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM) + } + }) + } +} + +func TestParseDMARCRecordSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedSP *string + expectedNP *string + }{ + { + name: "sp=none, no np", + record: "v=DMARC1; p=quarantine; sp=none", + expectedSP: utils.PtrTo("none"), + expectedNP: nil, + }, + { + name: "sp=reject, np=reject", + record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", + expectedSP: utils.PtrTo("quarantine"), + expectedNP: utils.PtrTo("reject"), + }, + { + name: "No sp or np (both default)", + record: "v=DMARC1; p=quarantine", + expectedSP: nil, + expectedNP: nil, + }, + { + name: "np=quarantine, no sp", + record: "v=DMARC1; p=reject; np=quarantine", + expectedSP: nil, + expectedNP: utils.PtrTo("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if tt.expectedSP == nil { + if rec.SubdomainPolicy != nil { + t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy) + } + } else { + if rec.SubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP) + } + if string(*rec.SubdomainPolicy) != *tt.expectedSP { + t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP) + } + } + if tt.expectedNP == nil { + if rec.NonexistentSubdomainPolicy != nil { + t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy) + } + } else { + if rec.NonexistentSubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP) + } + if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP { + t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP) + } + } + }) + } +} + +func TestParseDMARCRecordPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + {name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)}, + {name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)}, + {name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)}, + {name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil}, + {name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil}, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go new file mode 100644 index 0000000..07e5ab9 --- /dev/null +++ b/pkg/analyzer/dns_fcr.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/model" +) + +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + +// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability +func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) { + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 50 points for having PTR records + score += 50 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 15 + } + + // Additional 50 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go new file mode 100644 index 0000000..c48c9a4 --- /dev/null +++ b/pkg/analyzer/dns_mx.go @@ -0,0 +1,116 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// checkMXRecords looks up MX records for a domain +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + mxRecords, err := d.resolver.LookupMX(ctx, domain) + if err != nil { + return &[]model.MXRecord{ + { + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + }, + } + } + + if len(mxRecords) == 0 { + return &[]model.MXRecord{ + { + Valid: false, + Error: utils.PtrTo("No MX records found"), + }, + } + } + + var results []model.MXRecord + for _, mx := range mxRecords { + results = append(results, model.MXRecord{ + Host: mx.Host, + Priority: mx.Pref, + Valid: true, + }) + } + + return &results +} + +func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) { + // Having valid MX records is critical for email deliverability + // From domain MX records (half points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 50 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go new file mode 100644 index 0000000..f60484f --- /dev/null +++ b/pkg/analyzer/dns_resolver.go @@ -0,0 +1,80 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" +) + +// DNSResolver defines the interface for DNS resolution operations. +// This interface abstracts DNS lookups to allow for custom implementations, +// such as mock resolvers for testing or caching resolvers for performance. +type DNSResolver interface { + // LookupMX returns the DNS MX records for the given domain. + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + + // LookupTXT returns the DNS TXT records for the given domain. + LookupTXT(ctx context.Context, name string) ([]string, error) + + // LookupAddr performs a reverse lookup for the given IP address, + // returning a list of hostnames mapping to that address. + LookupAddr(ctx context.Context, addr string) ([]string, error) + + // LookupHost looks up the given hostname using the local resolver. + // It returns a slice of that host's addresses (IPv4 and IPv6). + LookupHost(ctx context.Context, host string) ([]string, error) +} + +// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver. +type StandardDNSResolver struct { + resolver *net.Resolver +} + +// NewStandardDNSResolver creates a new StandardDNSResolver with default settings. +func NewStandardDNSResolver() DNSResolver { + return &StandardDNSResolver{ + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// LookupMX implements DNSResolver.LookupMX using net.Resolver. +func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + return r.resolver.LookupMX(ctx, name) +} + +// LookupTXT implements DNSResolver.LookupTXT using net.Resolver. +func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + return r.resolver.LookupTXT(ctx, name) +} + +// LookupAddr implements DNSResolver.LookupAddr using net.Resolver. +func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + return r.resolver.LookupAddr(ctx, addr) +} + +// LookupHost implements DNSResolver.LookupHost using net.Resolver. +func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.resolver.LookupHost(ctx, host) +} diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go new file mode 100644 index 0000000..ccb1674 --- /dev/null +++ b/pkg/analyzer/dns_spf.go @@ -0,0 +1,368 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord { + visited := make(map[string]bool) + return d.resolveSPFRecords(domain, visited, 0, true) +} + +// resolveSPFRecords recursively resolves SPF records including include: directives +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord { + const maxDepth = 10 // Prevent infinite recursion + + if depth > maxDepth { + return &[]model.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: utils.PtrTo("Maximum SPF include depth exceeded"), + }, + } + } + + // Prevent circular references + if visited[domain] { + return &[]model.SPFRecord{} + } + visited[domain] = true + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, domain) + if err != nil { + return &[]model.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + }, + } + } + + // Find SPF record (starts with "v=spf1") + var spfRecord string + spfCount := 0 + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=spf1") { + spfRecord = txt + spfCount++ + } + } + + if spfCount == 0 { + return &[]model.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: utils.PtrTo("No SPF record found"), + }, + } + } + + var results []model.SPFRecord + + if spfCount > 1 { + results = append(results, model.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: false, + Error: utils.PtrTo("Multiple SPF records found (RFC violation)"), + }) + return &results + } + + // Basic validation + validationErr := d.validateSPF(spfRecord, isMainRecord) + + // Extract the "all" mechanism qualifier + var allQualifier *model.SPFRecordAllQualifier + var errMsg *string + + if validationErr != nil { + errMsg = utils.PtrTo(validationErr.Error()) + } else { + // Extract qualifier from the "all" mechanism + if strings.HasSuffix(spfRecord, " -all") { + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-")) + } else if strings.HasSuffix(spfRecord, " ~all") { + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~")) + } else if strings.HasSuffix(spfRecord, " +all") { + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+")) + } else if strings.HasSuffix(spfRecord, " ?all") { + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?")) + } else if strings.HasSuffix(spfRecord, " all") { + // Implicit + qualifier (default) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+")) + } + } + + results = append(results, model.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: validationErr == nil, + AllQualifier: allQualifier, + Error: errMsg, + }) + + // Check for redirect= modifier first (it replaces the entire SPF policy) + redirectDomain := d.extractSPFRedirect(spfRecord) + if redirectDomain != "" { + // redirect= replaces the current domain's policy entirely + // Only follow if no other mechanisms matched (per RFC 7208) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false) + if redirectRecords != nil { + results = append(results, *redirectRecords...) + } + return &results + } + + // Extract and resolve include: directives + includes := d.extractSPFIncludes(spfRecord) + for _, includeDomain := range includes { + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false) + if includedRecords != nil { + results = append(results, *includedRecords...) + } + } + + return &results +} + +// extractSPFIncludes extracts all include: domains from an SPF record +func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { + var includes []string + re := regexp.MustCompile(`include:([^\s]+)`) + matches := re.FindAllStringSubmatch(record, -1) + for _, match := range matches { + if len(match) > 1 { + includes = append(includes, match[1]) + } + } + return includes +} + +// extractSPFRedirect extracts the redirect= domain from an SPF record +// The redirect= modifier replaces the current domain's SPF policy with that of the target domain +func (d *DNSAnalyzer) extractSPFRedirect(record string) string { + re := regexp.MustCompile(`redirect=([^\s]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// isValidSPFMechanism checks if a token is a valid SPF mechanism or modifier +func (d *DNSAnalyzer) isValidSPFMechanism(token string) error { + // Remove qualifier prefix if present (+, -, ~, ?) + mechanism := strings.TrimLeft(token, "+-~?") + + // Check if it's a modifier (contains =) + if strings.Contains(mechanism, "=") { + // Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=) + if strings.HasPrefix(mechanism, "redirect=") || + strings.HasPrefix(mechanism, "exp=") || + strings.HasPrefix(mechanism, "ra=") || + strings.HasPrefix(mechanism, "rp=") || + strings.HasPrefix(mechanism, "rr=") { + return nil + } + + // Check if it's a common mistake (using = instead of :) + parts := strings.SplitN(mechanism, "=", 2) + if len(parts) == 2 { + mechanismName := parts[0] + knownMechanisms := []string{"include", "a", "mx", "ptr", "exists"} + for _, known := range knownMechanisms { + if mechanismName == known { + return fmt.Errorf("invalid syntax '%s': mechanism '%s' should use ':' not '='", token, mechanismName) + } + } + } + + return fmt.Errorf("unknown modifier '%s'", token) + } + + // Check standalone mechanisms (no domain/value required) + if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" { + return nil + } + + // Check mechanisms with domain/value + knownPrefixes := []string{ + "include:", + "a:", "a/", + "mx:", "mx/", + "ptr:", + "ip4:", + "ip6:", + "exists:", + } + + for _, prefix := range knownPrefixes { + if strings.HasPrefix(mechanism, prefix) { + return nil + } + } + + return fmt.Errorf("unknown mechanism '%s'", token) +} + +// validateSPF performs basic SPF record validation +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error { + // Must start with v=spf1 + if !strings.HasPrefix(record, "v=spf1") { + return fmt.Errorf("SPF record must start with 'v=spf1'") + } + + // Parse and validate each token in the SPF record + tokens := strings.Fields(record) + hasRedirect := false + + for i, token := range tokens { + // Skip the version tag + if i == 0 && token == "v=spf1" { + continue + } + + // Check if it's a valid mechanism + if err := d.isValidSPFMechanism(token); err != nil { + return err + } + + // Track if we have a redirect modifier + mechanism := strings.TrimLeft(token, "+-~?") + if strings.HasPrefix(mechanism, "redirect=") { + hasRedirect = true + } + } + + // Check for redirect= modifier (which replaces the need for an 'all' mechanism) + if hasRedirect { + return nil + } + + // Only check for 'all' mechanism on the main record, not on included records + if isMainRecord { + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } + } + + if !hasValidEnding { + return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier") + } + } + + return nil +} + +// hasSPFStrictFail checks if SPF record has strict -all mechanism +func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { + return strings.HasSuffix(record, " -all") +} + +func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) { + // SPF is essential for email authentication + if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { + // Find the main SPF record by skipping redirects + // Loop through records to find the last redirect or the first non-redirect + mainSPFIndex := 0 + for i := 0; i < len(*results.SpfRecords); i++ { + spfRecord := (*results.SpfRecords)[i] + if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { + // This is a redirect, check if there's a next record + if i+1 < len(*results.SpfRecords) { + mainSPFIndex = i + 1 + } else { + // Redirect exists but no target record found + break + } + } else { + // Found a non-redirect record + mainSPFIndex = i + break + } + } + + mainSPF := (*results.SpfRecords)[mainSPFIndex] + if mainSPF.Valid { + // Full points for valid SPF + score += 75 + + // Check if DMARC is configured with strict policy as all mechanism is less significant + dmarcStrict := results.DmarcRecord != nil && + results.DmarcRecord.Valid && results.DmarcRecord.Policy != nil && + (*results.DmarcRecord.Policy == "quarantine" || + *results.DmarcRecord.Policy == "reject") + + // Deduct points based on the all mechanism qualifier + if mainSPF.AllQualifier != nil { + switch *mainSPF.AllQualifier { + case "-": + // Strict fail - no deduction, this is the recommended policy + score += 25 + case "~": + // Softfail - if DMARC is quarantine or reject, treat it mostly like strict fail + if dmarcStrict { + score += 20 + } + // Otherwise, moderate penalty (no points added or deducted) + case "+", "?": + // Pass/neutral - severe penalty + if !dmarcStrict { + score -= 25 + } + } + } else { + // No 'all' mechanism qualifier extracted - severe penalty + score -= 25 + } + } else if mainSPF.Record != nil { + // Partial credit if SPF record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go new file mode 100644 index 0000000..2e794ce --- /dev/null +++ b/pkg/analyzer/dns_spf_test.go @@ -0,0 +1,284 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "strings" + "testing" + "time" +) + +func TestValidateSPF(t *testing.T) { + tests := []struct { + name string + record string + expectError bool + errorMsg string // Expected error message (substring match) + }{ + { + name: "Valid SPF with -all", + record: "v=spf1 include:_spf.example.com -all", + expectError: false, + }, + { + name: "Valid SPF with ~all", + record: "v=spf1 ip4:192.0.2.0/24 ~all", + expectError: false, + }, + { + name: "Valid SPF with +all", + record: "v=spf1 +all", + expectError: false, + }, + { + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expectError: false, + }, + { + name: "Valid SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectError: false, + }, + { + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expectError: false, + }, + { + name: "Valid SPF with multiple mechanisms", + record: "v=spf1 a mx ip4:192.0.2.0/24 include:_spf.example.com -all", + expectError: false, + }, + { + name: "Valid SPF with exp modifier", + record: "v=spf1 mx exp=explain.example.com -all", + expectError: false, + }, + { + name: "Invalid SPF - no version", + record: "include:_spf.example.com -all", + expectError: true, + errorMsg: "must start with 'v=spf1'", + }, + { + name: "Invalid SPF - no all mechanism or redirect", + record: "v=spf1 include:_spf.example.com", + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Invalid SPF - wrong version", + record: "v=spf2 include:_spf.example.com -all", + expectError: true, + errorMsg: "must start with 'v=spf1'", + }, + { + name: "Invalid SPF - include= instead of include:", + record: "v=spf1 include=icloud.com ~all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - a= instead of a:", + record: "v=spf1 a=example.com -all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - mx= instead of mx:", + record: "v=spf1 mx=example.com -all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - unknown mechanism", + record: "v=spf1 foobar -all", + expectError: true, + errorMsg: "unknown mechanism", + }, + { + name: "Invalid SPF - unknown modifier", + record: "v=spf1 -all unknown=value", + expectError: true, + errorMsg: "unknown modifier", + }, + { + name: "Valid SPF with RFC 6652 ra modifier", + record: "v=spf1 mx ra=postmaster -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rp modifier", + record: "v=spf1 mx rp=100 -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rr modifier", + record: "v=spf1 mx rr=all -all", + expectError: false, + }, + { + name: "Valid SPF with all RFC 6652 modifiers", + record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 modifiers and redirect", + record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com", + expectError: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test as main record (isMainRecord = true) since these tests check overall SPF validity + err := analyzer.validateSPF(tt.record, true) + if tt.expectError { + if err == nil { + t.Errorf("validateSPF(%q) expected error but got nil", tt.record) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateSPF(%q) error = %q, want error containing %q", tt.record, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateSPF(%q) unexpected error: %v", tt.record, err) + } + } + }) + } +} + +func TestValidateSPF_IncludedRecords(t *testing.T) { + tests := []struct { + name string + record string + isMainRecord bool + expectError bool + errorMsg string + }{ + { + name: "Main record without 'all' - should error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record without 'all' - should NOT error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: false, + expectError: false, + }, + { + name: "Included record with only mechanisms - should NOT error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with only mechanisms - should error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: true, + expectError: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := analyzer.validateSPF(tt.record, tt.isMainRecord) + if tt.expectError { + if err == nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err) + } + } + }) + } +} + +func TestExtractSPFRedirect(t *testing.T) { + tests := []struct { + name string + record string + expectedRedirect string + }{ + { + name: "SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectedRedirect: "_spf.example.com", + }, + { + name: "SPF with redirect and other mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", + expectedRedirect: "_spf.google.com", + }, + { + name: "SPF without redirect", + record: "v=spf1 include:_spf.example.com -all", + expectedRedirect: "", + }, + { + name: "SPF with only all mechanism", + record: "v=spf1 -all", + expectedRedirect: "", + }, + { + name: "Empty record", + record: "", + expectedRedirect: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractSPFRedirect(tt.record) + if result != tt.expectedRedirect { + t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) + } + }) + } +} diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 12a6bd0..bba4503 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -22,12 +22,8 @@ package analyzer import ( - "net/mail" - "strings" "testing" "time" - - "git.happydns.org/happyDeliver/internal/api" ) func TestNewDNSAnalyzer(t *testing.T) { @@ -60,761 +56,3 @@ func TestNewDNSAnalyzer(t *testing.T) { }) } } - -func TestExtractDomain(t *testing.T) { - tests := []struct { - name string - fromAddress string - expectedDomain string - }{ - { - name: "Valid email", - fromAddress: "user@example.com", - expectedDomain: "example.com", - }, - { - name: "Email with subdomain", - fromAddress: "user@mail.example.com", - expectedDomain: "mail.example.com", - }, - { - name: "Email with uppercase", - fromAddress: "User@Example.COM", - expectedDomain: "example.com", - }, - { - name: "Invalid email (no @)", - fromAddress: "invalid-email", - expectedDomain: "", - }, - { - name: "Empty email", - fromAddress: "", - expectedDomain: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - } - if tt.fromAddress != "" { - email.From = &mail.Address{ - Address: tt.fromAddress, - } - } - - domain := analyzer.extractDomain(email) - if domain != tt.expectedDomain { - t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain) - } - }) - } -} - -func TestValidateSPF(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid SPF with -all", - record: "v=spf1 include:_spf.example.com -all", - expected: true, - }, - { - name: "Valid SPF with ~all", - record: "v=spf1 ip4:192.0.2.0/24 ~all", - expected: true, - }, - { - name: "Valid SPF with +all", - record: "v=spf1 +all", - expected: true, - }, - { - name: "Valid SPF with ?all", - record: "v=spf1 mx ?all", - expected: true, - }, - { - name: "Invalid SPF - no version", - record: "include:_spf.example.com -all", - expected: false, - }, - { - name: "Invalid SPF - no all mechanism", - record: "v=spf1 include:_spf.example.com", - expected: false, - }, - { - name: "Invalid SPF - wrong version", - record: "v=spf2 include:_spf.example.com -all", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateSPF(tt.record) - if result != tt.expected { - t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestValidateDKIM(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DKIM with version", - record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Valid DKIM without version", - record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Invalid DKIM - no public key", - record: "v=DKIM1; k=rsa", - expected: false, - }, - { - name: "Invalid DKIM - wrong version", - record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: false, - }, - { - name: "Invalid DKIM - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDKIM(tt.record) - if result != tt.expected { - t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractDMARCPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy string - }{ - { - name: "Policy none", - record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", - expectedPolicy: "none", - }, - { - name: "Policy quarantine", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPolicy: "quarantine", - }, - { - name: "Policy reject", - record: "v=DMARC1; p=reject; sp=reject", - expectedPolicy: "reject", - }, - { - name: "No policy", - record: "v=DMARC1", - expectedPolicy: "unknown", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) - } - }) - } -} - -func TestValidateDMARC(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DMARC", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - expected: true, - }, - { - name: "Valid DMARC minimal", - record: "v=DMARC1; p=none", - expected: true, - }, - { - name: "Invalid DMARC - no version", - record: "p=quarantine", - expected: false, - }, - { - name: "Invalid DMARC - no policy", - record: "v=DMARC1", - expected: false, - }, - { - name: "Invalid DMARC - wrong version", - record: "v=DMARC2; p=reject", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDMARC(tt.record) - if result != tt.expected { - t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestGenerateMXCheck(t *testing.T) { - tests := []struct { - name string - results *DNSResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - {Host: "mail2.example.com", Priority: 20, Valid: true}, - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "No MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "No MX records found"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "MX lookup failed", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "DNS lookup failed"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateMXCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateSPFCheck(t *testing.T) { - tests := []struct { - name string - spf *SPFRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid SPF", - spf: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Invalid SPF", - spf: &SPFRecord{ - Record: "v=spf1 invalid syntax", - Valid: false, - Error: "SPF record appears malformed", - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - { - name: "No SPF record", - spf: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateSPFCheck(tt.spf) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateDKIMCheck(t *testing.T) { - tests := []struct { - name string - dkim *DKIMRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=MIGfMA0...", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Invalid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Valid: false, - Error: "No DKIM record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDKIMCheck(tt.dkim) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - if !strings.Contains(check.Name, tt.dkim.Selector) { - t.Errorf("Check name should contain selector %s", tt.dkim.Selector) - } - }) - } -} - -func TestGenerateDMARCCheck(t *testing.T) { - tests := []struct { - name string - dmarc *DMARCRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid DMARC - reject", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=reject", - Policy: "reject", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Valid DMARC - quarantine", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Valid DMARC - none", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=none", - Policy: "none", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "No DMARC record", - dmarc: &DMARCRecord{ - Valid: false, - Error: "No DMARC record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDMARCCheck(tt.dmarc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - - // Check that advice mentions policy for valid DMARC - if tt.dmarc.Valid && check.Advice != nil { - if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") { - t.Error("Advice should mention 'none' policy") - } - } - }) - } -} - -func TestGenerateDNSChecks(t *testing.T) { - tests := []struct { - name string - results *DNSResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "Complete results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - }, - minChecks: 4, // MX, SPF, DKIM, DMARC - }, - { - name: "Partial results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - }, - minChecks: 1, // Only MX - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateDNSChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the DNS category - for _, check := range checks { - if check.Category != api.Dns { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns) - } - } - }) - } -} - -func TestAnalyzeDNS_NoDomain(t *testing.T) { - analyzer := NewDNSAnalyzer(5 * time.Second) - email := &EmailMessage{ - Header: make(mail.Header), - // No From address - } - - results := analyzer.AnalyzeDNS(email, nil) - - if results == nil { - t.Fatal("Expected results, got nil") - } - - if len(results.Errors) == 0 { - t.Error("Expected error when no domain can be extracted") - } -} - -func TestExtractBIMITag(t *testing.T) { - tests := []struct { - name string - record string - tag string - expectedValue string - }{ - { - name: "Extract logo URL (l tag)", - record: "v=BIMI1; l=https://example.com/logo.svg", - tag: "l", - expectedValue: "https://example.com/logo.svg", - }, - { - name: "Extract VMC URL (a tag)", - record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", - tag: "a", - expectedValue: "https://example.com/vmc.pem", - }, - { - name: "Tag not found", - record: "v=BIMI1; l=https://example.com/logo.svg", - tag: "a", - expectedValue: "", - }, - { - name: "Tag with spaces", - record: "v=BIMI1; l= https://example.com/logo.svg ", - tag: "l", - expectedValue: "https://example.com/logo.svg", - }, - { - name: "Empty record", - record: "", - tag: "l", - expectedValue: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractBIMITag(tt.record, tt.tag) - if result != tt.expectedValue { - t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) - } - }) - } -} - -func TestValidateBIMI(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid BIMI with logo URL", - record: "v=BIMI1; l=https://example.com/logo.svg", - expected: true, - }, - { - name: "Valid BIMI with logo and VMC", - record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", - expected: true, - }, - { - name: "Invalid BIMI - no version", - record: "l=https://example.com/logo.svg", - expected: false, - }, - { - name: "Invalid BIMI - wrong version", - record: "v=BIMI2; l=https://example.com/logo.svg", - expected: false, - }, - { - name: "Invalid BIMI - no logo URL", - record: "v=BIMI1", - expected: false, - }, - { - name: "Invalid BIMI - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateBIMI(tt.record) - if result != tt.expected { - t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestGenerateBIMICheck(t *testing.T) { - tests := []struct { - name string - bimi *BIMIRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid BIMI with logo only", - bimi: &BIMIRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=BIMI1; l=https://example.com/logo.svg", - LogoURL: "https://example.com/logo.svg", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score - }, - { - name: "Valid BIMI with VMC", - bimi: &BIMIRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", - LogoURL: "https://example.com/logo.svg", - VMCURL: "https://example.com/vmc.pem", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, - }, - { - name: "No BIMI record (optional)", - bimi: &BIMIRecord{ - Selector: "default", - Domain: "example.com", - Valid: false, - Error: "No BIMI record found", - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, - }, - { - name: "Invalid BIMI record", - bimi: &BIMIRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=BIMI1", - Valid: false, - Error: "BIMI record appears malformed", - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateBIMICheck(tt.bimi) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - if check.Name != "BIMI Record" { - t.Errorf("Name = %q, want %q", check.Name, "BIMI Record") - } - - // Check details for valid BIMI with VMC - if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil { - if !strings.Contains(*check.Details, "VMC URL") { - t.Error("Details should contain VMC URL for valid BIMI with VMC") - } - } - }) - } -} diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go new file mode 100644 index 0000000..6d7b547 --- /dev/null +++ b/pkg/analyzer/headers.go @@ -0,0 +1,697 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "net" + "net/mail" + "regexp" + "strings" + "time" + + "golang.org/x/net/publicsuffix" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// HeaderAnalyzer analyzes email header quality and structure +type HeaderAnalyzer struct{} + +// NewHeaderAnalyzer creates a new header analyzer +func NewHeaderAnalyzer() *HeaderAnalyzer { + return &HeaderAnalyzer{} +} + +// CalculateHeaderScore evaluates email structural quality from header analysis +func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) { + if analysis == nil || analysis.Headers == nil { + return 0, ' ' + } + + score := 0 + maxGrade := 6 + headers := *analysis.Headers + + // RP and From alignment (25 points) + if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned { + // Bad domain alignment, cap grade to C + maxGrade -= 2 + } else if *analysis.DomainAlignment.Aligned { + score += 25 + } else if *analysis.DomainAlignment.RelaxedAligned { + score += 20 + } + + // Check required headers (RFC 5322) - 30 points + requiredHeaders := []string{"from", "date", "message-id"} + requiredCount := len(requiredHeaders) + presentRequired := 0 + + for _, headerName := range requiredHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRequired++ + } + } + + if presentRequired == requiredCount { + score += 30 + } else { + score += int(30 * (float32(presentRequired) / float32(requiredCount))) + maxGrade = 1 + } + + // Check recommended headers (15 points) + recommendedHeaders := []string{"subject", "to"} + + // Add reply-to when from is a no-reply address + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } + + recommendedCount := len(recommendedHeaders) + presentRecommended := 0 + + for _, headerName := range recommendedHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRecommended++ + } + } + score += presentRecommended * 15 / recommendedCount + + if presentRecommended < recommendedCount { + maxGrade -= 1 + } + + // Check for proper MIME structure (20 points) + if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { + score += 20 + } else { + maxGrade -= 1 + } + + // Check MIME-Version header (-5 points if present but not "1.0") + if check, exists := headers["mime-version"]; exists && check.Present { + if check.Valid != nil && !*check.Valid { + score -= 5 + } + } + + // Check Message-ID format (10 points) + if check, exists := headers["message-id"]; exists && check.Present { + // If Valid is set and true, award points + if check.Valid != nil && *check.Valid { + score += 10 + } else { + maxGrade -= 1 + } + } else { + maxGrade -= 1 + } + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + grade := 'A' + max(6-maxGrade, 0) + + return score, rune(grade) +} + +// isValidMessageID checks if a Message-ID has proper format +func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { + // Basic check: should be in format <...@...> + if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { + return false + } + + // Remove angle brackets + messageID = strings.TrimPrefix(messageID, "<") + messageID = strings.TrimSuffix(messageID, ">") + + // Should contain @ symbol + if !strings.Contains(messageID, "@") { + return false + } + + parts := strings.Split(messageID, "@") + if len(parts) != 2 { + return false + } + + // Both parts should be non-empty + return len(parts[0]) > 0 && len(parts[1]) > 0 +} + +// parseEmailDate attempts to parse an email date string using common email date formats +// Returns the parsed time and an error if parsing fails +func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) { + // Remove timezone name in parentheses if present + dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "") + + // Try parsing with common email date formats + formats := []string{ + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04:05 -0700", + } + + for _, format := range formats { + if parsedTime, err := time.Parse(format, dateStr); err == nil { + return parsedTime, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr) +} + +// isNoReplyAddress checks if a header check represents a no-reply email address +func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool { + if !headerCheck.Present || headerCheck.Value == nil { + return false + } + + value := strings.ToLower(*headerCheck.Value) + noReplyPatterns := []string{ + "no-reply", + "noreply", + "ne-pas-repondre", + "nepasrepondre", + } + + for _, pattern := range noReplyPatterns { + if strings.Contains(value, pattern) { + return true + } + } + + return false +} + +// validateAddressHeader validates email address header using net/mail parser +// and returns the normalized address string in "Name " format +func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) { + // Try to parse as a single address first + if addr, err := mail.ParseAddress(value); err == nil { + return h.formatAddress(addr), nil + } + + // If single address parsing fails, try parsing as an address list + // (for headers like To, Cc that can contain multiple addresses) + if addrs, err := mail.ParseAddressList(value); err != nil { + return "", err + } else { + // Join multiple addresses with ", " + result := "" + for i, addr := range addrs { + if i > 0 { + result += ", " + } + result += h.formatAddress(addr) + } + return result, nil + } +} + +// formatAddress formats a mail.Address as "Name " or just "email" if no name +func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { + if addr.Name != "" { + return fmt.Sprintf("%s <%s>", addr.Name, addr.Address) + } + return addr.Address +} + +// GenerateHeaderAnalysis creates structured header analysis from email +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis { + if email == nil { + return nil + } + + analysis := &model.HeaderAnalysis{} + + // Check for proper MIME structure + analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0) + + // Initialize headers map + headers := make(map[string]model.HeaderCheck) + + // Check required headers + requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"} + for _, headerName := range requiredHeaders { + check := h.checkHeader(email, headerName, "required") + headers[strings.ToLower(headerName)] = *check + } + + // Check recommended headers + recommendedHeaders := []string{} + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } + for _, headerName := range recommendedHeaders { + check := h.checkHeader(email, headerName, "recommended") + headers[strings.ToLower(headerName)] = *check + } + + // Check MIME-Version header (recommended but absence is not penalized) + mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended") + headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck + + // Check optional headers + optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"} + for _, headerName := range optionalHeaders { + check := h.checkHeader(email, headerName, "newsletter") + headers[strings.ToLower(headerName)] = *check + } + + analysis.Headers = &headers + + // Received chain + receivedChain := h.parseReceivedChain(email) + if len(receivedChain) > 0 { + analysis.ReceivedChain = &receivedChain + } + + // Domain alignment + domainAlignment := h.analyzeDomainAlignment(email, authResults) + if domainAlignment != nil { + analysis.DomainAlignment = domainAlignment + } + + // Header issues + issues := h.findHeaderIssues(email) + if len(issues) > 0 { + analysis.Issues = &issues + } + + return analysis +} + +// checkHeader checks if a header is present and valid +func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck { + value := email.GetHeaderValue(headerName) + present := email.HasHeader(headerName) && value != "" + + importanceEnum := model.HeaderCheckImportance(importance) + check := &model.HeaderCheck{ + Present: present, + Importance: &importanceEnum, + } + + if present { + check.Value = &value + + // Validate specific headers + valid := true + var headerIssues []string + + switch headerName { + case "Message-ID": + if !h.isValidMessageID(value) { + valid = false + headerIssues = append(headerIssues, "Invalid Message-ID format (should be )") + } + if len(email.Header["Message-Id"]) > 1 { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"]))) + } + case "Date": + // Validate date format + if _, err := h.parseEmailDate(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err)) + } + case "MIME-Version": + if value != "1.0" { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value)) + } + case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path": + // Parse address header using net/mail and get normalized address + if normalizedAddr, err := h.validateAddressHeader(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err)) + } else { + // Use the normalized address as the value + check.Value = &normalizedAddr + } + } + + check.Valid = &valid + if len(headerIssues) > 0 { + check.Issues = &headerIssues + } + } else { + valid := false + check.Valid = &valid + if importance == "required" { + issues := []string{"Required header is missing"} + check.Issues = &issues + } + } + + return check +} + +// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures +func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment { + alignment := &model.DomainAlignment{ + Aligned: utils.PtrTo(true), + RelaxedAligned: utils.PtrTo(true), + } + + // Extract From domain + fromAddr := email.GetHeaderValue("From") + if fromAddr != "" { + domain := h.extractDomain(fromAddr) + if domain != "" { + alignment.FromDomain = &domain + // Extract organizational domain + orgDomain := getOrganizationalDomain(domain) + alignment.FromOrgDomain = &orgDomain + } + } + + // Extract Return-Path domain + returnPath := email.GetHeaderValue("Return-Path") + if returnPath != "" { + domain := h.extractDomain(returnPath) + if domain != "" { + alignment.ReturnPathDomain = &domain + // Extract organizational domain + orgDomain := getOrganizationalDomain(domain) + alignment.ReturnPathOrgDomain = &orgDomain + } + } + + // Extract DKIM domains from authentication results + var dkimDomains []model.DKIMDomainInfo + if authResults != nil && authResults.Dkim != nil { + for _, dkim := range *authResults.Dkim { + if dkim.Domain != nil && *dkim.Domain != "" { + domain := *dkim.Domain + orgDomain := getOrganizationalDomain(domain) + dkimDomains = append(dkimDomains, model.DKIMDomainInfo{ + Domain: domain, + OrgDomain: orgDomain, + }) + } + } + } + if len(dkimDomains) > 0 { + alignment.DkimDomains = &dkimDomains + } + + // Check alignment (strict and relaxed) + issues := []string{} + + // hasReturnPath and hasDKIM track whether we have these fields to check + hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil + hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0 + + // If neither Return-Path nor DKIM is present, keep default alignment (true) + // Otherwise, at least one must be aligned for overall alignment to be true + strictAligned := !hasReturnPath && !hasDKIM + relaxedAligned := !hasReturnPath && !hasDKIM + + // Check Return-Path alignment + rpStrictAligned := false + rpRelaxedAligned := false + if hasReturnPath { + fromDomain := *alignment.FromDomain + rpDomain := *alignment.ReturnPathDomain + + // Strict alignment: exact match (case-insensitive) + rpStrictAligned = strings.EqualFold(fromDomain, rpDomain) + + // Relaxed alignment: organizational domain match + var fromOrgDomain, rpOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + if alignment.ReturnPathOrgDomain != nil { + rpOrgDomain = *alignment.ReturnPathOrgDomain + } + rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain) + + if !rpStrictAligned { + if rpRelaxedAligned { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain)) + } else { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) + } + } + + strictAligned = rpStrictAligned + relaxedAligned = rpRelaxedAligned + } + + // Check DKIM alignment + dkimStrictAligned := false + dkimRelaxedAligned := false + if hasDKIM { + fromDomain := *alignment.FromDomain + var fromOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + + for _, dkimDomain := range dkimDomains { + // Check strict alignment for this DKIM signature + if strings.EqualFold(fromDomain, dkimDomain.Domain) { + dkimStrictAligned = true + } + + // Check relaxed alignment for this DKIM signature + if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) { + dkimRelaxedAligned = true + } + } + + if !dkimStrictAligned && !dkimRelaxedAligned { + // List all DKIM domains that failed alignment + dkimDomainsList := []string{} + for _, dkimDomain := range dkimDomains { + dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain) + } + issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain)) + } else if !dkimStrictAligned && dkimRelaxedAligned { + // DKIM has relaxed alignment but not strict + issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain)) + } + + // Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned + // For DMARC compliance, at least one of SPF or DKIM must be aligned + if dkimStrictAligned { + strictAligned = true + } + if dkimRelaxedAligned { + relaxedAligned = true + } + } + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + + if len(issues) > 0 { + alignment.Issues = &issues + } + + return alignment +} + +// extractDomain extracts domain from email address +func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { + // Remove angle brackets if present + emailAddr = strings.Trim(emailAddr, "<> ") + + // Find @ symbol + atIndex := strings.LastIndex(emailAddr, "@") + if atIndex == -1 { + return "" + } + + domain := emailAddr[atIndex+1:] + // Remove any trailing > + domain = strings.TrimRight(domain, ">") + + return domain +} + +// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name +// using the Public Suffix List (PSL) to correctly handle multi-level TLDs. +// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk +func getOrganizationalDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + + // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) + // This correctly handles cases like .co.uk, .com.au, etc. + etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + // Fallback to simple two-label extraction if PSL lookup fails + labels := strings.Split(domain, ".") + if len(labels) <= 2 { + return domain + } + return strings.Join(labels[len(labels)-2:], ".") + } + + return etldPlusOne +} + +// findHeaderIssues identifies issues with headers +func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue { + var issues []model.HeaderIssue + + // Check for missing required headers + requiredHeaders := []string{"From", "Date", "Message-ID"} + for _, header := range requiredHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + issues = append(issues, model.HeaderIssue{ + Header: header, + Severity: model.HeaderIssueSeverityCritical, + Message: fmt.Sprintf("Required header '%s' is missing", header), + Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)), + }) + } + } + + // Check Message-ID format + messageID := email.GetHeaderValue("Message-ID") + if messageID != "" && !h.isValidMessageID(messageID) { + issues = append(issues, model.HeaderIssue{ + Header: "Message-ID", + Severity: model.HeaderIssueSeverityMedium, + Message: "Message-ID format is invalid", + Advice: utils.PtrTo("Use proper Message-ID format: "), + }) + } + + return issues +} + +// parseReceivedChain extracts the chain of Received headers from an email +func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop { + if email == nil || email.Header == nil { + return nil + } + + receivedHeaders := email.Header["Received"] + if len(receivedHeaders) == 0 { + return nil + } + + var chain []model.ReceivedHop + + for _, receivedValue := range receivedHeaders { + hop := h.parseReceivedHeader(receivedValue) + if hop != nil { + chain = append(chain, *hop) + } + } + + return chain +} + +// parseReceivedHeader parses a single Received header value +func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop { + hop := &model.ReceivedHop{} + + // Normalize whitespace - Received headers can span multiple lines + normalized := strings.Join(strings.Fields(receivedValue), " ") + + // Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)") + // vs standard "from-first" header (e.g., "from hostname ... by hostname") + isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized)) + + // Extract "from" field - only if not in "by-first" format + // Avoid matching "from" inside parentheses after "by" + if !isByFirst { + fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`) + if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 { + from := matches[1] + hop.From = &from + } + } + + // Extract "by" field + byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`) + if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 { + by := matches[1] + hop.By = &by + } + + // Extract "with" field (protocol) - must come after "by" and before "id" or "for" + // This ensures we get the mail transfer protocol, not other "with" occurrences + // Avoid matching "with" inside parentheses (like in TLS details) + withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`) + if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 { + with := matches[1] + hop.With = &with + } + + // Extract "id" field - should come after "with" or "by", not inside parentheses + // Match pattern: "id " where value doesn't contain parentheses or semicolons + idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`) + if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 { + id := matches[1] + hop.Id = &id + } + + // Extract IP address from parentheses after "from" + // Pattern: from hostname (anything [IPv4/IPv6]) + ipRegex := regexp.MustCompile(`\[([^\]]+)\]`) + if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 { + ipStr := matches[1] + + // Handle IPv6: prefix (some MTAs include this) + ipStr = strings.TrimPrefix(ipStr, "IPv6:") + + // Check if it's a valid IP (IPv4 or IPv6) + if net.ParseIP(ipStr) != nil { + hop.Ip = &ipStr + + // Perform reverse DNS lookup + if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 { + // Remove trailing dot from PTR record + reverse := strings.TrimSuffix(reverseNames[0], ".") + hop.Reverse = &reverse + } + } + } + + // Extract timestamp - usually at the end after semicolon + // Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)" + timestampRegex := regexp.MustCompile(`;\s*(.+)$`) + if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 { + timestampStr := strings.TrimSpace(matches[1]) + + // Use the dedicated date parsing function + if parsedTime, err := h.parseEmailDate(timestampStr); err == nil { + hop.Timestamp = &parsedTime + } + } + + return hop +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go new file mode 100644 index 0000000..d7469d7 --- /dev/null +++ b/pkg/analyzer/headers_test.go @@ -0,0 +1,1079 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestCalculateHeaderScore(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minScore int + maxScore int + }{ + { + name: "Nil email", + email: nil, + minScore: 0, + maxScore: 0, + }, + { + name: "Perfect headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0, + maxScore: 40, + }, + { + name: "Required only, no recommended", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 80, + maxScore: 90, + }, + { + name: "Invalid Message-ID format", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "invalid-message-id", + "Subject": "Test", + "To": "recipient@example.com", + "Reply-To": "reply@example.com", + }), + MessageID: "invalid-message-id", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate header analysis first + analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil) + score, _ := analyzer.CalculateHeaderScore(analysis) + if score < tt.minScore || score > tt.maxScore { + t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + }) + } +} + +func TestCheckHeader(t *testing.T) { + tests := []struct { + name string + headerName string + headerValue string + importance string + expectedPresent bool + expectedValid bool + expectedIssuesLen int + }{ + { + name: "Valid Message-ID", + headerName: "Message-ID", + headerValue: "", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Invalid Message-ID format", + headerName: "Message-ID", + headerValue: "invalid-message-id", + importance: "required", + expectedPresent: true, + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Missing required header", + headerName: "From", + headerValue: "", + importance: "required", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Missing optional header", + headerName: "Reply-To", + headerValue: "", + importance: "optional", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 0, + }, + { + name: "Valid Date header", + headerName: "Date", + headerValue: "Mon, 01 Jan 2024 12:00:00 +0000", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + tt.headerName: tt.headerValue, + }), + } + + check := analyzer.checkHeader(email, tt.headerName, tt.importance) + + if check.Present != tt.expectedPresent { + t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent) + } + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + if check.Importance == nil { + t.Error("Importance is nil") + } else if string(*check.Importance) != tt.importance { + t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen) + } + }) + } +} + +func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) { + tests := []struct { + name string + messageID string + expected bool + }{ + { + name: "Valid Message-ID", + messageID: "", + expected: true, + }, + { + name: "Valid with complex local part", + messageID: "", + expected: true, + }, + { + name: "Missing angle brackets", + messageID: "abc123@example.com", + expected: false, + }, + { + name: "Missing @ symbol", + messageID: "", + expected: false, + }, + { + name: "Empty local part", + messageID: "<@example.com>", + expected: false, + }, + { + name: "Empty domain", + messageID: "", + expected: false, + }, + { + name: "Multiple @ symbols", + messageID: "", + expected: false, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.isValidMessageID(tt.messageID) + if result != tt.expected { + t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) + } + }) + } +} + +func TestHeaderAnalyzer_ExtractDomain(t *testing.T) { + tests := []struct { + name string + email string + expected string + }{ + { + name: "Simple email", + email: "user@example.com", + expected: "example.com", + }, + { + name: "Email with angle brackets", + email: "", + expected: "example.com", + }, + { + name: "Email with display name", + email: "User Name ", + expected: "example.com", + }, + { + name: "Email with spaces", + email: " user@example.com ", + expected: "example.com", + }, + { + name: "Invalid email", + email: "not-an-email", + expected: "", + }, + { + name: "Empty string", + email: "", + expected: "", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDomain(tt.email) + if result != tt.expected { + t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected) + } + }) + } +} + +func TestAnalyzeDomainAlignment(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + expectAligned bool + expectIssuesLen int + }{ + { + name: "Aligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + expectAligned: true, + expectIssuesLen: 0, + }, + { + name: "Misaligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + expectAligned: false, + expectIssuesLen: 1, + }, + { + name: "Only From header", + fromHeader: "sender@example.com", + returnPath: "", + expectAligned: true, + expectIssuesLen: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + alignment := analyzer.analyzeDomainAlignment(email, nil) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned) + } + + issuesLen := 0 + if alignment.Issues != nil { + issuesLen = len(*alignment.Issues) + } + if issuesLen != tt.expectIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen) + } + }) + } +} + +// Helper function to create mail.Header with specific fields +func createHeaderWithFields(fields map[string]string) mail.Header { + header := make(mail.Header) + for key, value := range fields { + if value != "" { + // Use canonical MIME header key format + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + header[canonicalKey] = []string{value} + } + } + return header +} + +func TestParseReceivedChain(t *testing.T) { + tests := []struct { + name string + receivedHeaders []string + expectedHops int + validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop) + }{ + { + name: "No Received headers", + receivedHeaders: []string{}, + expectedHops: 0, + }, + { + name: "Single Received header", + receivedHeaders: []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "mail.example.com" { + t.Errorf("From = %v, want 'mail.example.com'", hop.From) + } + if hop.By == nil || *hop.By != "mx.receiver.com" { + t.Errorf("By = %v, want 'mx.receiver.com'", hop.By) + } + if hop.With == nil || *hop.With != "ESMTPS" { + t.Errorf("With = %v, want 'ESMTPS'", hop.With) + } + if hop.Id == nil || *hop.Id != "ABC123" { + t.Errorf("Id = %v, want 'ABC123'", hop.Id) + } + if hop.Ip == nil || *hop.Ip != "192.0.2.1" { + t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip) + } + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + }, + }, + { + name: "Multiple Received headers", + receivedHeaders: []string{ + "from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000", + "from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000", + }, + expectedHops: 2, + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { + if len(hops) != 2 { + t.Fatalf("Expected 2 hops, got %d", len(hops)) + } + + // Check first hop + if hops[0].From == nil || *hops[0].From != "mail1.example.com" { + t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From) + } + + // Check second hop + if hops[1].From == nil || *hops[1].From != "mail2.example.com" { + t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From) + } + }, + }, + { + name: "IPv6 address", + receivedHeaders: []string{ + "from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.Ip == nil { + t.Fatal("IP should not be nil for IPv6 address") + } + // Should strip the "IPv6:" prefix + if *hop.Ip != "2607:5300:203:2818::1" { + t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip) + } + }, + }, + { + name: "Multiline Received header", + receivedHeaders: []string{ + `from nemunai.re (unknown [IPv6:2607:5300:203:2818::1]) + (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) + key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) + (No client certificate requested) + (Authenticated sender: nemunaire) + by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA + for ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`, + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "nemunai.re" { + t.Errorf("From = %v, want 'nemunai.re'", hop.From) + } + if hop.By == nil || *hop.By != "djehouty.pomail.fr" { + t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By) + } + if hop.With == nil { + t.Error("With should not be nil") + } else if *hop.With != "ESMTPSA" { + t.Errorf("With = %q, want 'ESMTPSA'", *hop.With) + } + if hop.Id == nil || *hop.Id != "1EFD11611EA" { + t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id) + } + }, + }, + { + name: "Received header with minimal information", + receivedHeaders: []string{ + "from unknown by localhost", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "unknown" { + t.Errorf("From = %v, want 'unknown'", hop.From) + } + if hop.By == nil || *hop.By != "localhost" { + t.Errorf("By = %v, want 'localhost'", hop.By) + } + }, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(mail.Header) + if len(tt.receivedHeaders) > 0 { + header["Received"] = tt.receivedHeaders + } + + email := &EmailMessage{ + Header: header, + } + + chain := analyzer.parseReceivedChain(email) + + if len(chain) != tt.expectedHops { + t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops) + } + + if tt.validateFirst != nil { + tt.validateFirst(t, email, chain) + } + }) + } +} + +func TestParseReceivedHeader(t *testing.T) { + tests := []struct { + name string + receivedValue string + expectFrom *string + expectBy *string + expectWith *string + expectId *string + expectIp *string + expectHasTs bool + }{ + { + name: "Complete Received header", + receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("ESMTPS"), + expectId: strPtr("ABC123"), + expectIp: strPtr("192.0.2.1"), + expectHasTs: true, + }, + { + name: "Minimal Received header", + receivedValue: "from sender.example.com by receiver.example.com", + expectFrom: strPtr("sender.example.com"), + expectBy: strPtr("receiver.example.com"), + expectWith: nil, + expectId: nil, + expectIp: nil, + expectHasTs: false, + }, + { + name: "Received header with ESMTPA", + receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500", + expectFrom: strPtr("[192.0.2.50]"), + expectBy: strPtr("mail.example.com"), + expectWith: strPtr("ESMTPA"), + expectId: strPtr("XYZ789"), + expectIp: strPtr("192.0.2.50"), + expectHasTs: true, + }, + { + name: "Received header without IP", + receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("SMTP"), + expectId: nil, + expectIp: nil, + expectHasTs: true, + }, + { + name: "Postfix local delivery with userid", + receivedValue: "by grunt.ycc.fr (Postfix, from userid 1000) id 67276801A8; Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectFrom: nil, + expectBy: strPtr("grunt.ycc.fr"), + expectWith: nil, + expectId: strPtr("67276801A8"), + expectIp: nil, + expectHasTs: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hop := analyzer.parseReceivedHeader(tt.receivedValue) + + if hop == nil { + t.Fatal("parseReceivedHeader returned nil") + } + + // Check From + if !equalStrPtr(hop.From, tt.expectFrom) { + t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom)) + } + + // Check By + if !equalStrPtr(hop.By, tt.expectBy) { + t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy)) + } + + // Check With + if !equalStrPtr(hop.With, tt.expectWith) { + t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith)) + } + + // Check Id + if !equalStrPtr(hop.Id, tt.expectId) { + t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId)) + } + + // Check Ip + if !equalStrPtr(hop.Ip, tt.expectIp) { + t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp)) + } + + // Check Timestamp + if tt.expectHasTs { + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + } + }) + } +} + +func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { + analyzer := NewHeaderAnalyzer() + + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + } + + // Add Received headers + email.Header["Received"] = []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000", + "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000", + } + + analysis := analyzer.GenerateHeaderAnalysis(email, nil) + + if analysis == nil { + t.Fatal("GenerateHeaderAnalysis returned nil") + } + + if analysis.ReceivedChain == nil { + t.Fatal("ReceivedChain should not be nil") + } + + chain := *analysis.ReceivedChain + if len(chain) != 2 { + t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain)) + } + + // Check first hop + if chain[0].From == nil || *chain[0].From != "mail.example.com" { + t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From) + } + + // Check second hop + if chain[1].From == nil || *chain[1].From != "relay.example.com" { + t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From) + } +} + +func TestHeaderAnalyzer_ParseEmailDate(t *testing.T) { + tests := []struct { + name string + dateStr string + expectError bool + expectYear int + expectMonth int + expectDay int + }{ + { + name: "RFC1123Z format", + dateStr: "Mon, 02 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "RFC1123 format", + dateStr: "Mon, 02 Jan 2006 15:04:05 MST", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Single digit day", + dateStr: "Mon, 2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Without day of week", + dateStr: "2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "With timezone name in parentheses", + dateStr: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectError: false, + expectYear: 2024, + expectMonth: 1, + expectDay: 1, + }, + { + name: "With timezone name in parentheses 2", + dateStr: "Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 19, + }, + { + name: "With CEST timezone", + dateStr: "Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 24, + }, + { + name: "Invalid date format", + dateStr: "not a date", + expectError: true, + }, + { + name: "Empty string", + dateStr: "", + expectError: true, + }, + { + name: "ISO 8601 format (should fail)", + dateStr: "2024-01-01T12:00:00Z", + expectError: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := analyzer.parseEmailDate(tt.dateStr) + + if tt.expectError { + if err == nil { + t.Errorf("parseEmailDate(%q) expected error, got nil", tt.dateStr) + } + } else { + if err != nil { + t.Errorf("parseEmailDate(%q) unexpected error: %v", tt.dateStr, err) + return + } + + if result.Year() != tt.expectYear { + t.Errorf("Year = %d, want %d", result.Year(), tt.expectYear) + } + if int(result.Month()) != tt.expectMonth { + t.Errorf("Month = %d, want %d", result.Month(), tt.expectMonth) + } + if result.Day() != tt.expectDay { + t.Errorf("Day = %d, want %d", result.Day(), tt.expectDay) + } + } + }) + } +} + +func TestCheckHeader_DateValidation(t *testing.T) { + tests := []struct { + name string + dateValue string + expectedValid bool + expectedIssuesLen int + }{ + { + name: "Valid RFC1123Z date", + dateValue: "Mon, 02 Jan 2006 15:04:05 -0700", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Valid date with timezone name", + dateValue: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Invalid date format", + dateValue: "2024-01-01", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Invalid date string", + dateValue: "not a date", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Empty date", + dateValue: "", + expectedValid: false, + expectedIssuesLen: 1, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Date": tt.dateValue, + }), + } + + check := analyzer.checkHeader(email, "Date", "required") + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d (issues: %v)", issuesLen, tt.expectedIssuesLen, check.Issues) + } + }) + } +} + +// Helper functions for testing +func strPtr(s string) *string { + return &s +} + +func ptrToStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func equalStrPtr(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + dkimDomains []string + expectStrictAligned bool + expectRelaxedAligned bool + expectIssuesContain string + }{ + { + name: "DKIM strict alignment with From domain", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "DKIM relaxed alignment only", + fromHeader: "sender@mail.example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: false, + expectRelaxedAligned: true, + expectIssuesContain: "relaxed alignment", + }, + { + name: "DKIM no alignment", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not align", + }, + { + name: "Multiple DKIM signatures - one aligns", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com", "example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Return-Path misaligned but DKIM aligned", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "Return-Path", + }, + { + name: "Return-Path aligned, no DKIM", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + dkimDomains: []string{}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Both Return-Path and DKIM misaligned", + fromHeader: "sender@example.com", + returnPath: "bounce@other.com", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + // Create authentication results with DKIM signatures + var authResults *model.AuthenticationResults + if len(tt.dkimDomains) > 0 { + dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains)) + for _, domain := range tt.dkimDomains { + dkimResults = append(dkimResults, model.AuthResult{ + Result: model.AuthResultResultPass, + Domain: &domain, + }) + } + authResults = &model.AuthenticationResults{ + Dkim: &dkimResults, + } + } + + alignment := analyzer.analyzeDomainAlignment(email, authResults) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectStrictAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned) + } + + if alignment.RelaxedAligned == nil { + t.Fatal("Expected non-nil RelaxedAligned field") + } + + if *alignment.RelaxedAligned != tt.expectRelaxedAligned { + t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned) + } + + // Check DKIM domains are populated + if len(tt.dkimDomains) > 0 { + if alignment.DkimDomains == nil { + t.Error("Expected DkimDomains to be populated") + } else if len(*alignment.DkimDomains) != len(tt.dkimDomains) { + t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains)) + } + } + + // Check issues contain expected string + if tt.expectIssuesContain != "" { + if alignment.Issues == nil || len(*alignment.Issues) == 0 { + t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain) + } else { + found := false + for _, issue := range *alignment.Issues { + if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) { + found = true + break + } + } + if !found { + t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues) + } + } + } + }) + } +} diff --git a/pkg/analyzer/parser.go b/pkg/analyzer/parser.go index 13c012c..00de151 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -211,8 +211,27 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -func (e *EmailMessage) GetAuthenticationResults() []string { - return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] +// If receiverHostname is provided, only returns headers that begin with that hostname +func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string { + allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] + + // If no hostname specified, return all results + if receiverHostname == "" { + return allResults + } + + // Filter results that begin with the specified hostname + var filtered []string + prefix := receiverHostname + ";" + for _, result := range allResults { + // Trim whitespace and check if it starts with hostname; + trimmed := strings.TrimSpace(result) + if strings.HasPrefix(trimmed, prefix) { + filtered = append(filtered, result) + } + } + + return filtered } // GetSpamAssassinHeaders extracts SpamAssassin-related headers @@ -230,6 +249,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string { } for _, headerName := range saHeaders { + if values, ok := e.Header[headerName]; ok && len(values) > 0 { + for _, value := range values { + if strings.TrimSpace(value) != "" { + headers[headerName] = value + break + } + } + } else if value := e.Header.Get(headerName); value != "" { + headers[headerName] = value + } + } + + return headers +} + +// GetRspamdHeaders extracts rspamd-related headers +func (e *EmailMessage) GetRspamdHeaders() map[string]string { + headers := make(map[string]string) + + rspamdHeaders := []string{ + "X-Spamd-Result", + "X-Rspamd-Score", + "X-Rspamd-Action", + "X-Rspamd-Server", + } + + for _, headerName := range rspamdHeaders { if value := e.Header.Get(headerName); value != "" { headers[headerName] = value } @@ -275,3 +321,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string { func (e *EmailMessage) HasHeader(key string) bool { return e.Header.Get(key) != "" } + +// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs. +// The header format is: , , ... +func (e *EmailMessage) GetListUnsubscribeURLs() []string { + value := e.Header.Get("List-Unsubscribe") + if value == "" { + return nil + } + var urls []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") { + urls = append(urls, part[1:len(part)-1]) + } + } + return urls +} diff --git a/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go index 571f542..196e8e2 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -120,7 +120,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults() + authResults := email.GetAuthenticationResults("example.com") if len(authResults) != 2 { t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index fb01ae0..31cccab 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -27,16 +27,22 @@ import ( "net" "regexp" "strings" + "sync" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) -// RBLChecker checks IP addresses against DNS-based blacklists -type RBLChecker struct { - Timeout time.Duration - RBLs []string - resolver *net.Resolver +// DNSListChecker checks IP addresses against DNS-based block/allow lists. +// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags. +type DNSListChecker struct { + Timeout time.Duration + Lists []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors + resolver *net.Resolver + informationalSet map[string]bool // Lists whose hits don't count toward the score } // DefaultRBLs is a list of commonly used RBL providers @@ -47,46 +53,83 @@ var DefaultRBLs = []string{ "b.barracudacentral.org", // Barracuda "cbl.abuseat.org", // CBL (Composite Blocking List) "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational) + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational) + "psbl.surriel.com", // PSBL + "dnsbl.dronebl.org", // DroneBL + "bl.mailspike.net", // Mailspike BL + "z.mailspike.net", // Mailspike Z + "bl.rbl-dns.com", // RBL-DNS + "bl.nszones.com", // NSZones +} + +// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score. +// These are typically broader lists where being listed is less definitive. +var DefaultInformationalRBLs = []string{ + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring +} + +// DefaultDNSWLs is a list of commonly used DNSWL providers +var DefaultDNSWLs = []string{ + "list.dnswl.org", // DNSWL.org — the main DNS whitelist + "swl.spamhaus.org", // Spamhaus Safe Whitelist } // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list -func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { +func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker { if timeout == 0 { - timeout = 5 * time.Second // Default timeout + timeout = 5 * time.Second } if len(rbls) == 0 { rbls = DefaultRBLs } - return &RBLChecker{ - Timeout: timeout, - RBLs: rbls, - resolver: &net.Resolver{ - PreferGo: true, - }, + informationalSet := make(map[string]bool, len(DefaultInformationalRBLs)) + for _, rbl := range DefaultInformationalRBLs { + informationalSet[rbl] = true + } + return &DNSListChecker{ + Timeout: timeout, + Lists: rbls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: true, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: informationalSet, } } -// RBLResults represents the results of RBL checks -type RBLResults struct { - Checks []RBLCheck - IPsChecked []string - ListedCount int +// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list +func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker { + if timeout == 0 { + timeout = 5 * time.Second + } + if len(dnswls) == 0 { + dnswls = DefaultDNSWLs + } + return &DNSListChecker{ + Timeout: timeout, + Lists: dnswls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: false, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: make(map[string]bool), + } } -// RBLCheck represents a single RBL check result -type RBLCheck struct { - IP string - RBL string - Listed bool - Response string - Error string +// DNSListResults represents the results of DNS list checks +type DNSListResults struct { + Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP + IPsChecked []string + ListedCount int // Total listings including informational entries + RelevantListedCount int // Listings on scoring (non-informational) lists only } -// CheckEmail checks all IPs found in the email headers against RBLs -func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{} +// CheckEmail checks all IPs found in the email headers against the configured lists +func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults { + results := &DNSListResults{ + Checks: make(map[string][]model.BlacklistCheck), + } - // Extract IPs from Received headers ips := r.extractIPs(email) if len(ips) == 0 { return results @@ -94,42 +137,68 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results.IPsChecked = ips - // Check each IP against all RBLs for _, ip := range ips { - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) - results.Checks = append(results.Checks, check) + for _, list := range r.Lists { + check := r.checkIP(ip, list) + results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ + if !r.informationalSet[list] { + results.RelevantListedCount++ + } } } + + if !r.CheckAllIPs { + break + } } return results } +// CheckIP checks a single IP address against all configured lists in parallel +func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) { + if !r.isPublicIP(ip) { + return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) + } + + checks := make([]model.BlacklistCheck, len(r.Lists)) + var wg sync.WaitGroup + + for i, list := range r.Lists { + wg.Add(1) + go func(i int, list string) { + defer wg.Done() + checks[i] = r.checkIP(ip, list) + }(i, list) + } + wg.Wait() + + listedCount := 0 + for _, check := range checks { + if check.Listed { + listedCount++ + } + } + + return checks, listedCount, nil +} + // extractIPs extracts IP addresses from Received headers -func (r *RBLChecker) extractIPs(email *EmailMessage) []string { +func (r *DNSListChecker) extractIPs(email *EmailMessage) []string { var ips []string seenIPs := make(map[string]bool) - // Get all Received headers receivedHeaders := email.Header["Received"] - - // Regex patterns for IP addresses - // Match IPv4: xxx.xxx.xxx.xxx ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) - // Look for IPs in Received headers for _, received := range receivedHeaders { - // Find all IPv4 addresses matches := ipv4Pattern.FindAllString(received, -1) for _, match := range matches { - // Skip private/reserved IPs if !r.isPublicIP(match) { continue } - // Avoid duplicates if !seenIPs[match] { ips = append(ips, match) seenIPs[match] = true @@ -137,13 +206,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } } - // If no IPs found in Received headers, try X-Originating-IP if len(ips) == 0 { originatingIP := email.Header.Get("X-Originating-IP") if originatingIP != "" { - // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1" cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") - // Remove any whitespace cleanIP = strings.TrimSpace(cleanIP) matches := ipv4Pattern.FindString(cleanIP) if matches != "" && r.isPublicIP(matches) { @@ -156,19 +222,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } // isPublicIP checks if an IP address is public (not private, loopback, or reserved) -func (r *RBLChecker) isPublicIP(ipStr string) bool { +func (r *DNSListChecker) isPublicIP(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } - // Check if it's a private network if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } - // Additional checks for reserved ranges - // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) if ip.IsUnspecified() { return false } @@ -176,233 +239,120 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { return true } -// checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { - check := RBLCheck{ - IP: ip, - RBL: rbl, +// checkIP checks a single IP against a single DNS list +func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck { + check := model.BlacklistCheck{ + Rbl: list, } - // Reverse the IP for DNSBL query reversedIP := r.reverseIP(ip) if reversedIP == "" { - check.Error = "Failed to reverse IP address" + check.Error = utils.PtrTo("Failed to reverse IP address") return check } - // Construct DNSBL query: reversed-ip.rbl-domain - query := fmt.Sprintf("%s.%s", reversedIP, rbl) + query := fmt.Sprintf("%s.%s", reversedIP, list) - // Perform DNS lookup with timeout ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) defer cancel() addrs, err := r.resolver.LookupHost(ctx, query) if err != nil { - // Most likely not listed (NXDOMAIN) if dnsErr, ok := err.(*net.DNSError); ok { if dnsErr.IsNotFound { check.Listed = false return check } } - // Other DNS errors - check.Error = fmt.Sprintf("DNS lookup failed: %v", err) + check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) return check } - // If we got a response, the IP is listed if len(addrs) > 0 { - check.Listed = true - check.Response = addrs[0] // Return code (e.g., 127.0.0.2) + check.Response = utils.PtrTo(addrs[0]) + + // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings. + if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") { + check.Listed = false + check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0])) + } else { + check.Listed = true + } } return check } -// reverseIP reverses an IPv4 address for DNSBL queries +// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries // Example: 192.0.2.1 -> 1.2.0.192 -func (r *RBLChecker) reverseIP(ipStr string) string { +func (r *DNSListChecker) reverseIP(ipStr string) string { ip := net.ParseIP(ipStr) if ip == nil { return "" } - // Convert to IPv4 ipv4 := ip.To4() if ipv4 == nil { return "" // IPv6 not supported yet } - // Reverse the octets return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points) -// Scoring: -// - Not listed on any RBL: 2 points (excellent) -// - Listed on 1 RBL: 1 point (warning) -// - Listed on 2-3 RBLs: 0.5 points (poor) -// - Listed on 4+ RBLs: 0 points (critical) -func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { - if results == nil || len(results.IPsChecked) == 0 { - // No IPs to check, give benefit of doubt - return 2.0 - } +// CalculateScore calculates the list contribution to deliverability. +// Informational lists are not counted in the score. +func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) { + scoringListCount := len(r.Lists) - len(r.informationalSet) - listedCount := results.ListedCount - - if listedCount == 0 { - return 2.0 - } else if listedCount == 1 { - return 1.0 - } else if listedCount <= 3 { - return 0.5 - } - - return 0.0 -} - -// GenerateRBLChecks generates check results for RBL analysis -func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // If no IPs were checked, add a warning - if len(results.IPsChecked) == 0 { - checks = append(checks, api.Check{ - Category: api.Blacklist, - Name: "RBL Check", - Status: api.CheckStatusWarn, - Score: 1.0, - Message: "No public IP addresses found to check", - Severity: api.PtrTo(api.CheckSeverityLow), - Advice: api.PtrTo("Unable to extract sender IP from email headers"), - }) - return checks - } - - // Create a summary check - summaryCheck := r.generateSummaryCheck(results) - checks = append(checks, summaryCheck) - - // Create individual checks for each listing - for _, check := range results.Checks { - if check.Listed { - detailCheck := r.generateListingCheck(&check) - checks = append(checks, detailCheck) + if forWhitelist { + if results.ListedCount >= scoringListCount { + return 100, "A++" + } else if results.ListedCount > 0 { + return 100, "A+" + } else { + return 95, "A" } } - return checks + if results == nil || len(results.IPsChecked) == 0 { + return 100, "" + } + + if results.ListedCount <= 0 || scoringListCount <= 0 { + return 100, "A+" + } + + percentage := 100 - results.RelevantListedCount*100/scoringListCount + return percentage, ScoreToGrade(percentage) } -// generateSummaryCheck creates an overall RBL summary check -func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: "RBL Summary", - } - - score := r.GetBlacklistScore(results) - check.Score = score - - totalChecks := len(results.Checks) - listedCount := results.ListedCount - - if listedCount == 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs)) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your sending IP has a good reputation") - } else if listedCount == 1 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") - } else if listedCount <= 3 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") - } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") - } - - // Add details about IPs checked - if len(results.IPsChecked) > 0 { - details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", ")) - check.Details = &details - } - - return check -} - -// generateListingCheck creates a check for a specific RBL listing -func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), - Status: api.CheckStatusFail, - Score: 0.0, - } - - check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) - - // Determine severity based on which RBL - if strings.Contains(rblCheck.RBL, "spamhaus") { - check.Severity = api.PtrTo(api.CheckSeverityCritical) - advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") - check.Advice = &advice - } else if strings.Contains(rblCheck.RBL, "spamcop") { - check.Severity = api.PtrTo(api.CheckSeverityHigh) - advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") - check.Advice = &advice - } else { - check.Severity = api.PtrTo(api.CheckSeverityHigh) - advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) - check.Advice = &advice - } - - // Add response code details - if rblCheck.Response != "" { - details := fmt.Sprintf("Response: %s", rblCheck.Response) - check.Details = &details - } - - return check -} - -// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL -func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { - seenIPs := make(map[string]bool) +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry +func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string { var listedIPs []string - for _, check := range results.Checks { - if check.Listed && !seenIPs[check.IP] { - listedIPs = append(listedIPs, check.IP) - seenIPs[check.IP] = true + for ip, checks := range results.Checks { + for _, check := range checks { + if check.Listed { + listedIPs = append(listedIPs, ip) + break + } } } return listedIPs } -// GetRBLsForIP returns all RBLs that list a specific IP -func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { - var rbls []string +// GetListsForIP returns all lists that match a specific IP +func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string { + var lists []string - for _, check := range results.Checks { - if check.IP == ip && check.Listed { - rbls = append(rbls, check.RBL) + if checks, exists := results.Checks[ip]; exists { + for _, check := range checks { + if check.Listed { + lists = append(lists, check.Rbl) + } } } - return rbls + return lists } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 3a2fd44..f86f17b 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -23,11 +23,10 @@ package analyzer import ( "net/mail" - "strings" "testing" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestNewRBLChecker(t *testing.T) { @@ -56,12 +55,12 @@ func TestNewRBLChecker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := NewRBLChecker(tt.timeout, tt.rbls) + checker := NewRBLChecker(tt.timeout, tt.rbls, false) if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.RBLs) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) + if len(checker.Lists) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -98,7 +97,7 @@ func TestReverseIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -158,7 +157,7 @@ func TestIsPublicIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -238,7 +237,7 @@ func TestExtractIPs(t *testing.T) { },*/ } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -266,68 +265,72 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults - expectedScore float32 + results *DNSListResults + expectedScore int }{ { name: "Nil results", results: nil, - expectedScore: 2.0, + expectedScore: 100, }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Listed on 1 RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, + results: &DNSListResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + RelevantListedCount: 1, }, - expectedScore: 1.0, + expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational) }, { name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, + results: &DNSListResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + RelevantListedCount: 2, }, - expectedScore: 0.5, + expectedScore: 84, // 100 - 2*100/12 = 84 }, { name: "Listed on 3 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 3, + results: &DNSListResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 3, + RelevantListedCount: 3, }, - expectedScore: 0.5, + expectedScore: 75, // 100 - 3*100/12 = 75 }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, + results: &DNSListResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + RelevantListedCount: 4, }, - expectedScore: 0.0, + expectedScore: 67, // 100 - 4*100/12 = 67 }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := checker.GetBlacklistScore(tt.results) + score, _ := checker.CalculateScore(tt.results, false) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -335,215 +338,24 @@ func TestGetBlacklistScore(t *testing.T) { } } -func TestGenerateSummaryCheck(t *testing.T) { - tests := []struct { - name string - results *RBLResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Not listed", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: make([]RBLCheck, 6), // 6 default RBLs - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 2.0, - }, - { - name: "Listed on 1 RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 1.0, - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - { - name: "Listed on 4+ RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateSummaryCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - }) - } -} - -func TestGenerateListingCheck(t *testing.T) { - tests := []struct { - name string - rblCheck *RBLCheck - expectedStatus api.CheckStatus - expectedSeverity api.CheckSeverity - }{ - { - name: "Spamhaus listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "zen.spamhaus.org", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityCritical, - }, - { - name: "SpamCop listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "bl.spamcop.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, - }, - { - name: "Other RBL listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "dnsbl.sorbs.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateListingCheck(tt.rblCheck) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Severity == nil || *check.Severity != tt.expectedSeverity { - t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - if !strings.Contains(check.Name, tt.rblCheck.RBL) { - t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL) - } - }) - } -} - -func TestGenerateRBLChecks(t *testing.T) { - tests := []struct { - name string - results *RBLResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "No IPs checked", - results: &RBLResults{ - IPsChecked: []string{}, - }, - minChecks: 1, // Warning check - }, - { - name: "Not listed on any RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false}, - }, - }, - minChecks: 1, // Summary check only - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, - }, - }, - minChecks: 3, // Summary + 2 listing checks - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := checker.GenerateRBLChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Blacklist category - for _, check := range checks { - if check.Category != api.Blacklist { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist) - } - } - }) - } -} - func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false}, - {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false}, + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ + "198.51.100.1": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, + }, + "198.51.100.2": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: false}, + }, + "198.51.100.3": { + {Rbl: "zen.spamhaus.org", Listed: false}, + }, }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) listedIPs := checker.GetUniqueListedIPs(results) expectedIPs := []string{"198.51.100.1", "198.51.100.2"} @@ -555,16 +367,20 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ + "198.51.100.1": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, + {Rbl: "dnsbl.sorbs.net", Listed: false}, + }, + "198.51.100.2": { + {Rbl: "zen.spamhaus.org", Listed: true}, + }, }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) tests := []struct { name string @@ -590,7 +406,7 @@ func TestGetRBLsForIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbls := checker.GetRBLsForIP(results, tt.ip) + rbls := checker.GetListsForIP(results, tt.ip) if len(rbls) != len(tt.expectedRBLs) { t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index fe30c6c..26cd59d 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -24,7 +24,8 @@ package analyzer import ( "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) @@ -32,37 +33,47 @@ import ( type ReportGenerator struct { authAnalyzer *AuthenticationAnalyzer spamAnalyzer *SpamAssassinAnalyzer + rspamdAnalyzer *RspamdAnalyzer dnsAnalyzer *DNSAnalyzer - rblChecker *RBLChecker + rblChecker *DNSListChecker + dnswlChecker *DNSListChecker contentAnalyzer *ContentAnalyzer - scorer *DeliverabilityScorer + headerAnalyzer *HeaderAnalyzer } // NewReportGenerator creates a new report generator func NewReportGenerator( + receiverHostname string, dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, + dnswls []string, + checkAllIPs bool, + rspamdAPIURL string, ) *ReportGenerator { return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(), + authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), spamAnalyzer: NewSpamAssassinAnalyzer(), + rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), - rblChecker: NewRBLChecker(dnsTimeout, rbls), + rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), + dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), contentAnalyzer: NewContentAnalyzer(httpTimeout), - scorer: NewDeliverabilityScorer(), + headerAnalyzer: NewHeaderAnalyzer(), } } // AnalysisResults contains all intermediate analysis results type AnalysisResults struct { Email *EmailMessage - Authentication *api.AuthenticationResults - SpamAssassin *SpamAssassinResult - DNS *DNSResults - RBL *RBLResults + Authentication *model.AuthenticationResults Content *ContentResults - Score *ScoringResult + DNS *model.DNSResults + Headers *model.HeaderAnalysis + RBL *DNSListResults + DNSWL *DNSListResults + SpamAssassin *model.SpamAssassinResult + Rspamd *model.RspamdResult } // AnalyzeEmail performs complete email analysis @@ -73,248 +84,217 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) + results.DNSWL = r.dnswlChecker.CheckEmail(email) + results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) + results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) - // Calculate overall score - results.Score = r.scorer.CalculateScore( - results.Authentication, - results.SpamAssassin, - results.RBL, - results.Content, - email, - ) - return results } // GenerateReport creates a complete API report from analysis results -func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report { +func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report { reportID := uuid.New() now := time.Now() - report := &api.Report{ - Id: reportID, - TestId: testID, - Score: results.Score.OverallScore, + report := &model.Report{ + Id: utils.UUIDToBase32(reportID), + TestId: utils.UUIDToBase32(testID), CreatedAt: now, } - // Build score summary - report.Summary = &api.ScoreSummary{ - AuthenticationScore: results.Score.AuthScore, - SpamScore: results.Score.SpamScore, - BlacklistScore: results.Score.BlacklistScore, - ContentScore: results.Score.ContentScore, - HeaderScore: results.Score.HeaderScore, - } - - // Collect all checks from different analyzers - checks := []api.Check{} - - // Authentication checks - if results.Authentication != nil { - authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication) - checks = append(checks, authChecks...) - } - - // DNS checks + // Calculate scores directly from analyzers (no more checks array) + dnsScore := 0 + var dnsGrade string if results.DNS != nil { - dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS) - checks = append(checks, dnsChecks...) + // Extract sender IP from received chain for FCrDNS verification + var senderIP string + if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 { + firstHop := (*results.Headers.ReceivedChain)[0] + if firstHop.Ip != nil { + senderIP = *firstHop.Ip + } + } + dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP) } - // RBL checks - if results.RBL != nil { - rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL) - checks = append(checks, rblChecks...) + authScore := 0 + var authGrade string + if results.Authentication != nil { + authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) } - // SpamAssassin checks - if results.SpamAssassin != nil { - spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin) - checks = append(checks, spamChecks...) - } - - // Content checks + contentScore := 0 + var contentGrade string if results.Content != nil { - contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content) - checks = append(checks, contentChecks...) + contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content) } - // Header checks - headerChecks := r.scorer.GenerateHeaderChecks(results.Email) - checks = append(checks, headerChecks...) + headerScore := 0 + var headerGrade rune + if results.Headers != nil { + headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers) + } - report.Checks = checks + blacklistScore := 0 + var blacklistGrade string + var whitelistGrade string + if results.RBL != nil { + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false) + _, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true) + } + + saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) + rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd) + + // Combine SpamAssassin and rspamd scores 50/50. + // If only one filter ran (the other returns "" grade), use that filter's score alone. + var spamScore int + var spamGrade string + switch { + case saGrade == "" && rspamdGrade == "": + spamScore = 0 + spamGrade = "" + case saGrade == "": + spamScore = rspamdScore + spamGrade = rspamdGrade + case rspamdGrade == "": + spamScore = saScore + spamGrade = saGrade + default: + spamScore = (saScore + rspamdScore) / 2 + spamGrade = MinGrade(saGrade, rspamdGrade) + } + + report.Summary = &model.ScoreSummary{ + DnsScore: dnsScore, + DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade), + AuthenticationScore: authScore, + AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade), + BlacklistScore: blacklistScore, + BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)), + ContentScore: contentScore, + ContentGrade: model.ScoreSummaryContentGrade(contentGrade), + HeaderScore: headerScore, + HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade), + SpamScore: spamScore, + SpamGrade: model.ScoreSummarySpamGrade(spamGrade), + } // Add authentication results report.Authentication = results.Authentication - // Add SpamAssassin result - if results.SpamAssassin != nil { - report.Spamassassin = &api.SpamAssassinResult{ - Score: float32(results.SpamAssassin.Score), - RequiredScore: float32(results.SpamAssassin.RequiredScore), - IsSpam: results.SpamAssassin.IsSpam, - } - - if len(results.SpamAssassin.Tests) > 0 { - report.Spamassassin.Tests = &results.SpamAssassin.Tests - } - - if results.SpamAssassin.RawReport != "" { - report.Spamassassin.Report = &results.SpamAssassin.RawReport - } + // Add content analysis + if results.Content != nil { + contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content) + report.ContentAnalysis = contentAnalysis } // Add DNS records if results.DNS != nil { - dnsRecords := r.buildDNSRecords(results.DNS) - if len(dnsRecords) > 0 { - report.DnsRecords = &dnsRecords - } + report.DnsResults = results.DNS } - // Add blacklist checks + // Add headers results + report.HeaderAnalysis = results.Headers + + // Add blacklist checks as a map of IP -> array of BlacklistCheck if results.RBL != nil && len(results.RBL.Checks) > 0 { - blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) - for _, check := range results.RBL.Checks { - blCheck := api.BlacklistCheck{ - Ip: check.IP, - Rbl: check.RBL, - Listed: check.Listed, - } - if check.Response != "" { - blCheck.Response = &check.Response - } - blacklistChecks = append(blacklistChecks, blCheck) - } - report.Blacklists = &blacklistChecks + report.Blacklists = &results.RBL.Checks } + // Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only) + if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 { + report.Whitelists = &results.DNSWL.Checks + } + + // Add SpamAssassin result with individual deliverability score + if results.SpamAssassin != nil { + saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade) + results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore) + results.SpamAssassin.DeliverabilityGrade = &saGradeTyped + } + report.Spamassassin = results.SpamAssassin + + // Add rspamd result with individual deliverability score + if results.Rspamd != nil { + rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade) + results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore) + results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped + } + report.Rspamd = results.Rspamd + // Add raw headers if results.Email != nil && results.Email.RawHeaders != "" { report.RawHeaders = &results.Email.RawHeaders } + // Calculate overall score as mean of all category scores + categoryScores := []int{ + report.Summary.DnsScore, + report.Summary.AuthenticationScore, + report.Summary.BlacklistScore, + report.Summary.ContentScore, + report.Summary.HeaderScore, + report.Summary.SpamScore, + } + + var totalScore int + var categoryCount int + for _, score := range categoryScores { + totalScore += score + categoryCount++ + } + + if categoryCount > 0 { + report.Score = totalScore / categoryCount + } else { + report.Score = 0 + } + + report.Grade = ScoreToReportGrade(report.Score) + categoryGrades := []string{ + string(report.Summary.DnsGrade), + string(report.Summary.AuthenticationGrade), + string(report.Summary.BlacklistGrade), + string(report.Summary.ContentGrade), + string(report.Summary.HeaderGrade), + string(report.Summary.SpamGrade), + } + if report.Score >= 100 { + hasLessThanA := false + + for _, grade := range categoryGrades { + if len(grade) < 1 || grade[0] != 'A' { + hasLessThanA = true + } + } + + if !hasLessThanA { + report.Grade = "A+" + } + } else { + var minusGrade byte = 0 + for _, grade := range categoryGrades { + if len(grade) == 0 { + minusGrade = 255 + break + } else if grade[0]-'A' > minusGrade { + minusGrade = grade[0] - 'A' + } + } + + if minusGrade < 255 { + report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade})) + } + } + return report } -// buildDNSRecords converts DNS analysis results to API DNS records -func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { - records := []api.DNSRecord{} - - if dns == nil { - return records - } - - // MX records - if len(dns.MXRecords) > 0 { - for _, mx := range dns.MXRecords { - status := api.Found - if !mx.Valid { - if mx.Error != "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.MX, - Status: status, - } - - if mx.Host != "" { - value := mx.Host - record.Value = &value - } - - records = append(records, record) - } - } - - // SPF record - if dns.SPFRecord != nil { - status := api.Found - if !dns.SPFRecord.Valid { - if dns.SPFRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.SPF, - Status: status, - } - - if dns.SPFRecord.Record != "" { - record.Value = &dns.SPFRecord.Record - } - - records = append(records, record) - } - - // DKIM records - for _, dkim := range dns.DKIMRecords { - status := api.Found - if !dkim.Valid { - if dkim.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dkim.Domain, - RecordType: api.DKIM, - Status: status, - } - - if dkim.Record != "" { - // Include selector in value for clarity - value := dkim.Record - record.Value = &value - } - - records = append(records, record) - } - - // DMARC record - if dns.DMARCRecord != nil { - status := api.Found - if !dns.DMARCRecord.Valid { - if dns.DMARCRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.DMARC, - Status: status, - } - - if dns.DMARCRecord.Record != "" { - record.Value = &dns.DMARCRecord.Record - } - - records = append(records, record) - } - - return records -} - // GenerateRawEmail returns the raw email message as a string func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { if email == nil { @@ -328,21 +308,3 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { return raw } - -// GetRecommendations returns actionable recommendations based on the score -func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { - if results == nil || results.Score == nil { - return []string{} - } - - return results.Score.Recommendations -} - -// GetScoreSummaryText returns a human-readable score summary -func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { - if results == nil || results.Score == nil { - return "" - } - - return r.scorer.GetScoreSummary(results.Score) -} diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 4a8fe00..5914737 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -24,16 +24,15 @@ package analyzer import ( "net/mail" "net/textproto" - "strings" "testing" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") if gen == nil { t.Fatal("Expected report generator, got nil") } @@ -53,13 +52,10 @@ func TestNewReportGenerator(t *testing.T) { if gen.contentAnalyzer == nil { t.Error("contentAnalyzer should not be nil") } - if gen.scorer == nil { - t.Error("scorer should not be nil") - } } func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") email := createTestEmail() @@ -76,24 +72,10 @@ func TestAnalyzeEmail(t *testing.T) { if results.Authentication == nil { t.Error("Authentication should not be nil") } - - // SpamAssassin might be nil if headers don't exist - // DNS results should exist - // RBL results should exist - // Content results should exist - - if results.Score == nil { - t.Error("Score should not be nil") - } - - // Verify score is within bounds - if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { - t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) - } } func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") testID := uuid.New() email := createTestEmail() @@ -106,15 +88,17 @@ func TestGenerateReport(t *testing.T) { } // Verify required fields - if report.Id == uuid.Nil { + if report.Id == "" { t.Error("Report ID should not be empty") } - if report.TestId != testID { - t.Errorf("TestId = %s, want %s", report.TestId, testID) + // Convert testID to base32 for comparison + expectedTestID := utils.UUIDToBase32(testID) + if report.TestId != expectedTestID { + t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID) } - if report.Score < 0 || report.Score > 10 { + if report.Score < 0 || report.Score > 100 { t.Errorf("Score %v is out of bounds", report.Score) } @@ -122,48 +106,31 @@ func TestGenerateReport(t *testing.T) { t.Error("Summary should not be nil") } - if len(report.Checks) == 0 { - t.Error("Checks should not be empty") - } - - // Verify score summary + // Verify score summary (all scores are 0-100 percentages) if report.Summary != nil { - if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { + if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 { t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) } - if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { + if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 { t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) } - if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 { t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) } - if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 { t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) } - if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 { t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) } - } - - // Verify checks have required fields - for i, check := range report.Checks { - if string(check.Category) == "" { - t.Errorf("Check %d: Category should not be empty", i) - } - if check.Name == "" { - t.Errorf("Check %d: Name should not be empty", i) - } - if string(check.Status) == "" { - t.Errorf("Check %d: Status should not be empty", i) - } - if check.Message == "" { - t.Errorf("Check %d: Message should not be empty", i) + if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 { + t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore) } } } func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") testID := uuid.New() email := createTestEmailWithSpamAssassin() @@ -182,101 +149,8 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } } -func TestBuildDNSRecords(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - dns *DNSResults - expectedCount int - expectTypes []api.DNSRecordRecordType - }{ - { - name: "Nil DNS results", - dns: nil, - expectedCount: 0, - }, - { - name: "Complete DNS results", - dns: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=...", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Valid: true, - }, - }, - expectedCount: 4, // MX, SPF, DKIM, DMARC - expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC}, - }, - { - name: "Missing records", - dns: &DNSResults{ - Domain: "example.com", - SPFRecord: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - }, - expectedCount: 1, - expectTypes: []api.DNSRecordRecordType{api.SPF}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - records := gen.buildDNSRecords(tt.dns) - - if len(records) != tt.expectedCount { - t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount) - } - - // Verify expected types are present - if tt.expectTypes != nil { - foundTypes := make(map[api.DNSRecordRecordType]bool) - for _, record := range records { - foundTypes[record.RecordType] = true - } - - for _, expectedType := range tt.expectTypes { - if !foundTypes[expectedType] { - t.Errorf("Expected DNS record type %s not found", expectedType) - } - } - } - - // Verify all records have required fields - for i, record := range records { - if record.Domain == "" { - t.Errorf("Record %d: Domain should not be empty", i) - } - if string(record.RecordType) == "" { - t.Errorf("Record %d: RecordType should not be empty", i) - } - if string(record.Status) == "" { - t.Errorf("Record %d: Status should not be empty", i) - } - } - }) - } -} - func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") tests := []struct { name string @@ -316,135 +190,6 @@ func TestGenerateRawEmail(t *testing.T) { } } -func TestGetRecommendations(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectCount int - }{ - { - name: "Nil results", - results: nil, - expectCount: 0, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 1.0, - BlacklistScore: 1.5, - ContentScore: 0.5, - HeaderScore: 0.5, - Recommendations: []string{ - "Improve authentication", - "Fix content issues", - }, - }, - }, - expectCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recs := gen.GetRecommendations(tt.results) - if len(recs) != tt.expectCount { - t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount) - } - }) - } -} - -func TestGetScoreSummaryText(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectEmpty bool - expectString string - }{ - { - name: "Nil results", - results: nil, - expectEmpty: true, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - }, - }, - expectEmpty: false, - expectString: "8.5/10", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - summary := gen.GetScoreSummaryText(tt.results) - if tt.expectEmpty { - if summary != "" { - t.Errorf("Expected empty summary, got %q", summary) - } - } else { - if summary == "" { - t.Error("Expected non-empty summary") - } - if tt.expectString != "" && !strings.Contains(summary, tt.expectString) { - t.Errorf("Summary should contain %q, got %q", tt.expectString, summary) - } - } - }) - } -} - -func TestReportCategories(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - testID := uuid.New() - - email := createComprehensiveTestEmail() - results := gen.AnalyzeEmail(email) - report := gen.GenerateReport(testID, results) - - // Verify all check categories are present - categories := make(map[api.CheckCategory]bool) - for _, check := range report.Checks { - categories[check.Category] = true - } - - expectedCategories := []api.CheckCategory{ - api.Authentication, - api.Dns, - api.Headers, - } - - for _, cat := range expectedCategories { - if !categories[cat] { - t.Errorf("Expected category %s not found in checks", cat) - } - } -} - // Helper functions func createTestEmail() *EmailMessage { @@ -481,21 +226,3 @@ func createTestEmailWithSpamAssassin() *EmailMessage { email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} return email } - -func createComprehensiveTestEmail() *EmailMessage { - email := createTestEmailWithSpamAssassin() - - // Add authentication headers - email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{ - "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass", - } - - // Add HTML content - email.Parts = append(email.Parts, MessagePart{ - ContentType: "text/html", - Content: "

Test

Link", - IsHTML: true, - }) - - return email -} diff --git a/pkg/analyzer/rspamd-symbols-README.md b/pkg/analyzer/rspamd-symbols-README.md new file mode 100644 index 0000000..882eab2 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols-README.md @@ -0,0 +1,21 @@ +# rspamd-symbols.json + +This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured. + +## How to update + +Fetch the latest symbols from a running rspamd instance: + +```sh +curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Or with docker: + +```sh +docker run --rm --name rspamd --pull always rspamd/rspamd +docker exec -u 0 rspamd apt install -y curl +docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Then rebuild the project. diff --git a/pkg/analyzer/rspamd-symbols.json b/pkg/analyzer/rspamd-symbols.json new file mode 100644 index 0000000..5538985 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols.json @@ -0,0 +1,6646 @@ +[ + { + "group": "arc", + "rules": [ + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rbl", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from SenderScore RPBL" + }, + { + "symbol": "RBL_VIRUSFREE_BOTNET", + "weight": 2.0, + "description": "From address is listed in virusfree.cz BL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_CRACKED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "SURBL_HASHBL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_MALWARE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_PHISH", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_EMAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_ABUSE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dnswl", + "rules": [ + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dmarc", + "rules": [ + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "statistics", + "rules": [ + { + "symbol": "BAYES_SPAM", + "weight": 5.100000, + "description": "Message probably spam, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAYES_HAM", + "weight": -3.0, + "description": "Message probably ham, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dkim", + "rules": [ + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "sem", + "rules": [ + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "neural", + "rules": [] + }, + { + "group": "policies", + "rules": [ + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surbl", + "rules": [ + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime", + "rules": [ + { + "symbol": "MIME_BASE64_TEXT_BOGUS", + "weight": 1.0, + "description": "Has text part encoded in base64 that does not contain any 8bit characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MIXED_BOGUS", + "weight": 1.0, + "description": "multipart/mixed without non-textual part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MISSING_DISPOSITION", + "weight": 4.0, + "description": "Binary content-type not specified as an attachment", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BASE64_TEXT", + "weight": 0.100000, + "description": "Has text part encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "multimap", + "rules": [ + { + "symbol": "DISPOSABLE_FROM", + "weight": 0.0, + "description": "From a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_TO", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_REPLYTO", + "weight": 0.0, + "description": "Reply-To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_CC", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_TO", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_MDN", + "weight": 0.500000, + "description": "Disposition-Notification-To is a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_MDN", + "weight": 0.0, + "description": "Disposition-Notification-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_FROM", + "weight": 0.0, + "description": "From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO", + "weight": 0.0, + "description": "Reply-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_CC", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL", + "weight": 0.0, + "description": "The presence of a redirector in the mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessqp", + "rules": [ + { + "symbol": "CC_EXCESS_QP", + "weight": 1.200000, + "description": "Cc header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_QP", + "weight": 1.200000, + "description": "Subject header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_QP", + "weight": 1.200000, + "description": "Reply-To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EXCESS_QP", + "weight": 1.200000, + "description": "From header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_QP", + "weight": 1.200000, + "description": "To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "upstream_spam_filters", + "rules": [ + { + "symbol": "UNITEDINTERNET_SPAM", + "weight": 5.0, + "description": "United Internet says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KLMS_SPAM", + "weight": 5.0, + "description": "Kaspersky Security for Mail Server says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MICROSOFT_SPAM", + "weight": 4.0, + "description": "Microsoft says the message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PRECEDENCE_BULK", + "weight": 0.0, + "description": "Message marked as bulk", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAM_FLAG", + "weight": 5.0, + "description": "Message was already marked as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers", + "rules": [ + { + "symbol": "FAKE_RECEIVED_smtp_yandex_ru", + "weight": 4.0, + "description": "Fake smtp.yandex.ru Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_RCONFIRM_MISMATCH", + "weight": 2.0, + "description": "Read confirmation address is different to from address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ZERO", + "weight": 0.0, + "description": "No recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILER_1C_8", + "weight": 0.0, + "description": "Sent with 1C:Enterprise 8", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPTO_QUOTE_YAHOO", + "weight": 2.0, + "description": "Quoted Reply-To header from Yahoo (seems to be forged)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_SEVEN", + "weight": 0.0, + "description": "Message has 7-11 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ZERO", + "weight": 0.0, + "description": "Message has no Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_DISPLAY_NAME", + "weight": 8.0, + "description": "Display name is being used to spoof and trick the recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_ALL", + "weight": 0.0, + "description": "All of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_FROM", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_EXCLAIM", + "weight": 0.0, + "description": "Subject ends with an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_IMS", + "weight": 3.0, + "description": "Forged X-Mailer: Internet Mail Service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER", + "weight": 0.300000, + "description": "Sender is forged (different From: header and smtp MAIL FROM: addresses)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ONE", + "weight": 0.0, + "description": "Message has one Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_RCPT_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in recipients headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_BOUN", + "weight": 2.0, + "description": "Forged The Bat! MUA headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAIL_RU_MAILER", + "weight": 0.0, + "description": "Sent with Mail.Ru webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_CC_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Cc header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OLD_X_MAILER", + "weight": 2.0, + "description": "X-Mailer header has a very old MUA version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED4", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_REPLY", + "weight": 1.0, + "description": "Fake reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STRONGMAIL", + "weight": 6.0, + "description": "Sent via rogue \"strongmail\" MTA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_FIVE", + "weight": 0.0, + "description": "Message has X-Priority header set to 5 or higher", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIME_VERSION", + "weight": 2.0, + "description": "MIME-Version header is missing in MIME message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RCVD", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DOUBLE_IP_SPAM", + "weight": 2.0, + "description": "Has two Received headers containing bare IP addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_REPLYTO", + "weight": 0.0, + "description": "Has Reply-To header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_HTML", + "weight": 1.0, + "description": "MIME multipart/alternative missing text/html part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DN_EQ_FROM_DN", + "weight": 0.0, + "description": "Reply-To display name matches From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHPOS_FAKE", + "weight": 3.0, + "description": "Fake X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_VERP", + "weight": 0.0, + "description": "Envelope From is a VERP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EQ_ENVFROM", + "weight": 0.0, + "description": "From address is the same as the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ORG_HEADER", + "weight": 0.0, + "description": "Has Organization header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_TO", + "weight": 2.0, + "description": "To header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS", + "weight": 10.0, + "description": "Headers structure is likely broken", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DN_EQ_ADDR", + "weight": 1.0, + "description": "From header display name is the same as the address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM_DOM", + "weight": 3.0, + "description": "The From and Reply-To addresses in the email are from different freemail services", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_HELO_LOCALHOST", + "weight": 0.0, + "description": "Localhost HELO seen in Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_BAD_CTE_7BIT", + "weight": 3.500000, + "description": "Detects bad Content-Transfer-Encoding for text parts", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FROM_EMPTY_DELIMITER", + "weight": 1.0, + "description": "From header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_QUESTION", + "weight": 0.0, + "description": "Subject contains a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ZERO", + "weight": 0.0, + "description": "Message has X-Priority header set to 0", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED", + "weight": 0.100000, + "description": "One received header in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INFO_TO_INFO_LU", + "weight": 2.0, + "description": "info@ From/To address with List-Unsubscribe headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_TEXT", + "weight": 2.0, + "description": "MIME multipart/alternative missing text/plain part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWO", + "weight": 0.0, + "description": "Two recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_THREE", + "weight": 0.0, + "description": "3-5 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO", + "weight": 0.0, + "description": "X-Priority check callback rule", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_NONE", + "weight": 0.0, + "description": "None of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWO", + "weight": 0.0, + "description": "Message has two Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTE_CASE", + "weight": 0.500000, + "description": "[78]Bit .vs. [78]bit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_EXCLAIM", + "weight": 0.0, + "description": "Subject contains an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_XM_UA", + "weight": 0.0, + "description": "Message has neither X-Mailer nor User-Agent header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_FORGED_0X", + "weight": 4.0, + "description": "X-PHP-Originating-Script header appears forged", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER", + "weight": 0.0, + "description": "Sent with Apple iPhone/iPad Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_LIST_UNSUB", + "weight": -0.010000, + "description": "Has List-Unsubscribe header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_INVALID", + "weight": 2.0, + "description": "Envelope from does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED3", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET", + "weight": 5.0, + "description": "Mixed characters in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID", + "weight": 1.700000, + "description": "Message-ID header is incorrect", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_SPACES", + "weight": 0.500000, + "description": "Subject ends with space characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWELVE", + "weight": 0.0, + "description": "Message has 12 or more Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_DISPLAY_NAME", + "weight": 4.0, + "description": "Display name contains an email address different to the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_CONTENT_TYPE", + "weight": 1.500000, + "description": "Message has part with broken content type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_DATE", + "weight": 1.0, + "description": "Date header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MSGID_YAHOO", + "weight": 2.0, + "description": "Forged Yahoo Message-ID header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_RCVD_SPAMBOTS", + "weight": 3.0, + "description": "Spambots signatures in received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MISSING_CHARSET", + "weight": 0.500000, + "description": "Charset header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID", + "weight": 2.500000, + "description": "Message-ID header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FORGED_MDN", + "weight": 2.0, + "description": "Read confirmation address is different to return path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_REPLYTO", + "weight": 6.0, + "description": "Reply-To is being used to spoof and trick the recipient to send an off-domain reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_DATE_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Date header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_SOME", + "weight": 0.0, + "description": "Some of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_FROM", + "weight": 2.0, + "description": "Missing From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_SEVEN", + "weight": 0.0, + "description": "7-11 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_UNPARSEABLE", + "weight": 1.0, + "description": "Reply-To header could not be parsed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ONE", + "weight": 0.0, + "description": "Message has X-Priority header set to 1", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_GT_50", + "weight": 0.0, + "description": "50+ recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_LAST", + "weight": 0.0, + "description": "Last hop used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_HAS_TITLE", + "weight": 1.0, + "description": "From header display name has a title (Mr/Mrs/Dr)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PREVIOUSLY_DELIVERED", + "weight": 0.0, + "description": "Message either to a list or was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_HELO_USER", + "weight": 3.0, + "description": "HELO User spam pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_X_MAILER", + "weight": 4.500000, + "description": "Forged X-Mailer header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_HTTP_URL_IN_FROM", + "weight": 5.0, + "description": "HTTP URL preceded by the start of a line, quote, or whitespace, with normal or URL-encoded colons in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "To domain is the same as the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWELVE", + "weight": 0.0, + "description": "12-50 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_TAGS", + "weight": 2.100000, + "description": "Message pretends to be send from Outlook but has 'strange' tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NO_DN", + "weight": 0.0, + "description": "From header does not have a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_DATE", + "weight": 1.500000, + "description": "Malformed Date header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_NO_SPACE_IN_FROM", + "weight": 1.0, + "description": "No space in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_HTML", + "weight": 5.0, + "description": "Forged Outlook HTML signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DISPLAY_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_ADDR_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to SMTP From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_WRAPPED_IN_SPACES", + "weight": 2.0, + "description": "To address is wrapped in spaces inside angle brackets (e.g. display-name < local-part@domain >)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DIRECT_TO_MX", + "weight": 0.0, + "description": "Message has been directly delivered from MUA to local MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_FIVE", + "weight": 0.0, + "description": "Message has 5-7 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_QUESTION", + "weight": 1.0, + "description": "Subject ends with a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS", + "weight": 2.0, + "description": "Recipients are not the same as RCPT TO: mail command", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TRACKER_ID", + "weight": 3.840000, + "description": "Spam string at the end of message to make statistics fault", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_ENVFROM", + "weight": 0.0, + "description": "From address is different to the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_EXTRA_SEMI", + "weight": 1.0, + "description": "Content-Type header ends with a semi-colon", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILLIST", + "weight": -0.200000, + "description": "Message seems to be from maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_TWO", + "weight": 0.0, + "description": "Message has X-Priority header set to 2", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_FIVE", + "weight": 0.0, + "description": "5-7 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_SUBJECT", + "weight": 2.0, + "description": "Subject header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CD_MM_BODY", + "weight": 2.0, + "description": "Content-Description header reads \"Mail message body\", commonly seen in spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "YANDEX_RU_MAILER", + "weight": 0.0, + "description": "Sent with Yandex webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_MISSING", + "weight": 2.500000, + "description": "Message was missing Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_NEEDS_ENCODING", + "weight": 1.0, + "description": "To header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEEDS_ENCODING", + "weight": 1.0, + "description": "From header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_NEEDS_ENCODING", + "weight": 1.0, + "description": "Subject needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_TO_ADDR", + "weight": 5.0, + "description": "Reply-To is the same as the To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EMAIL_HAS_TITLE", + "weight": 2.0, + "description": "Reply-To header has title", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ONE", + "weight": 0.0, + "description": "One recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EQ_FROM", + "weight": 0.0, + "description": "To address matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MIME", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_RECIPS", + "weight": 1.500000, + "description": "Recipients seems to be autogenerated (works if recipients count is more than 5)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_RECEIVED_mail_ru", + "weight": 4.0, + "description": "Fake HELO mail.ru in Received header from non-mail.ru sender address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XOIP", + "weight": 0.0, + "description": "Has X-Originating-IP header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EMPTY_SUBJECT", + "weight": 1.0, + "description": "Subject header is empty", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STOX_REPLY_TYPE", + "weight": 1.0, + "description": "Reply-type in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HEADER_CTYPE_ONLY", + "weight": 2.0, + "description": "Only Content-Type header without other MIME headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE", + "weight": -0.100000, + "description": "(Non) Delivery Status Notification", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SORTED_RECIPS", + "weight": 3.500000, + "description": "Recipients list seems to be sorted", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_POSTFIX_RECEIVED", + "weight": 3.0, + "description": "Invalid Postfix Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_PRVS", + "weight": 0.0, + "description": "Envelope From is a PRVS address that matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RECEIVED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIMEOLE", + "weight": 2.0, + "description": "Mime-OLE is needed but absent (e.g. fake Outlook or fake Exchange)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_HAS_DN", + "weight": 0.0, + "description": "From header has a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_NO_TLS_LAST", + "weight": 0.100000, + "description": "Last hop did not use encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_FROM_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RATWARE_MS_HASH", + "weight": 2.0, + "description": "Forged Exchange messages", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED_STRICT", + "weight": 4.0, + "description": "One received header with 'bad' patterns inside", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XM_CASE", + "weight": 0.500000, + "description": "X-mailer .vs. X-Mailer", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_PAST", + "weight": 1.0, + "description": "Message date is in the past", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_UNIQUE_HEADERS", + "weight": 7.0, + "description": "Repeated unique headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_THREE", + "weight": 0.0, + "description": "Message has X-Priority header set to 3 or 4", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_REPLYTO", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET_URL", + "weight": 7.0, + "description": "Mixed characters in a URL inside message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MV_CASE", + "weight": 0.500000, + "description": "Mime-Version .vs. MIME-Version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_UNDISC_RCPT", + "weight": 3.0, + "description": "Recipients are absent or undisclosed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER", + "weight": 0.0, + "description": "Sent with Apple Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_ALL", + "weight": 0.0, + "description": "All the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_BROKEN", + "weight": 1.700000, + "description": "Message had invalid Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_INVALID", + "weight": 2.0, + "description": "From header does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_FUTURE", + "weight": 4.0, + "description": "Message date is in the future", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_EXCESS_SPACE", + "weight": 1.0, + "description": "From header display name contains excess whitespace", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED2", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_THREE", + "weight": 0.0, + "description": "Message has 3-5 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_FROM", + "weight": 8.0, + "description": "Multiple addresses in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_CD_HEADER", + "weight": 0.0, + "description": "Has Content-Description header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_ALL", + "weight": 0.0, + "description": "All hops used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_ALL", + "weight": 0.0, + "description": "All of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_VIA_SMTP_AUTH", + "weight": 0.0, + "description": "Authenticated hand-off was seen in Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_RECIPIENTS", + "weight": 2.0, + "description": "To header display name is \"Recipients\"", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HTML_ONLY", + "weight": 0.200000, + "description": "Message has only an HTML part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_INTERSPIRE_SIG", + "weight": 1.0, + "description": "Has Interspire fingerprint", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_CURRENCY", + "weight": 1.0, + "description": "Subject contains currency", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_BOUNCE_WORDS", + "weight": 0.0, + "description": "Words/phrases typical for DSN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_REPLYTO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Reply-To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_TO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "phishing", + "rules": [ + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_REDIRECTOR_NESTED", + "weight": 1.0, + "description": "URL redirector nested limit has been reached" + }, + { + "symbol": "REDIRECTOR_FALSE", + "weight": 0.0, + "description": "Phishing exclusion symbol for known redirectors", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_EXCLUDED", + "weight": 0.0, + "description": "Phished URL found in exclusions list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHING", + "weight": 4.0, + "description": "Phished URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_OPENPHISH", + "weight": 7.0, + "description": "Phished URL found in openphish.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_GENERIC_SERVICE", + "weight": 0.0, + "description": "Phished URL found in generic service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_WHITELISTED", + "weight": 0.0, + "description": "Phishing exclusion symbol for known exceptions", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_PHISHTANK", + "weight": 7.0, + "description": "Phished URL found in phishtank.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessb64", + "rules": [ + { + "symbol": "FROM_EXCESS_BASE64", + "weight": 1.500000, + "description": "From header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_BASE64", + "weight": 1.500000, + "description": "Reply-To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_BASE64", + "weight": 1.500000, + "description": "To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CC_EXCESS_BASE64", + "weight": 1.500000, + "description": "Cc header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_BASE64", + "weight": 1.500000, + "description": "Subject header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "forwarding", + "rules": [ + { + "symbol": "FWD_MAILRU", + "weight": 0.0, + "description": "Message was forwarded by Mail.ru", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORWARDED", + "weight": 0.0, + "description": "Message was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_GOOGLE", + "weight": 0.0, + "description": "Message was forwarded by Google", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SIEVE", + "weight": 0.0, + "description": "Message was forwarded using Sieve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_CPANEL", + "weight": 0.0, + "description": "Message was forwarded using cPanel", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_YANDEX", + "weight": 0.0, + "description": "Message was forwarded by Yandex", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SRS", + "weight": 0.0, + "description": "Message was forwarded using Sender Rewriting Scheme (SRS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "url", + "rules": [ + { + "symbol": "HAS_FILE_URL", + "weight": 2.0, + "description": "Contains file:// URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BAD_UNICODE", + "weight": 3.0, + "description": "URL contains invalid Unicode", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_PASSWORD", + "weight": 2.0, + "description": "URL contains user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_OBFUSCATED_TEXT", + "weight": 5.0, + "description": "Obfuscated URL found in message text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_VERY_LONG", + "weight": 1.500000, + "description": "URL is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_HOMOGRAPH_ATTACK", + "weight": 5.0, + "description": "URL uses homograph attack (mixed scripts)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPICIOUS_TLD", + "weight": 3.0, + "description": "URL uses suspicious TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_REDIR", + "weight": 1.0, + "description": "Has google.com/url or alike Google redirection URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URI_COUNT_ODD", + "weight": 1.0, + "description": "Odd number of URIs in multipart/alternative message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_ZERO_WIDTH_SPACES", + "weight": 7.0, + "description": "URL contains zero-width spaces", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_LONG", + "weight": 3.0, + "description": "URL user field is long (>128 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_FIREBASE_URL", + "weight": 2.0, + "description": "Contains firebasestorage.googleapis.com URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_IPFS_GATEWAY_URL", + "weight": 6.0, + "description": "Message contains InterPlanetary File System (IPFS) gateway URL, likely malicious", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_RTL_OVERRIDE", + "weight": 6.0, + "description": "URL uses RTL override character", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_PRIVATE_IP", + "weight": 0.500000, + "description": "URL uses private IP range", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BACKSLASH_PATH", + "weight": 2.0, + "description": "URL uses backslashes", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP", + "weight": 1.500000, + "description": "URL uses numeric IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_VERY_LONG", + "weight": 5.0, + "description": "URL user field is very long (>256 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ONION_URI", + "weight": 0.0, + "description": "Contains .onion hidden service URI", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_EXCESSIVE_DOTS", + "weight": 2.0, + "description": "URL has excessive dots in hostname", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPECT_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NO_TLD", + "weight": 2.0, + "description": "URL has no TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OMOGRAPH_URL", + "weight": 5.0, + "description": "URL contains both latin and non-latin characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_MULTIPLE_AT_SIGNS", + "weight": 3.0, + "description": "URL has multiple @ signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP_USER", + "weight": 4.0, + "description": "URL uses numeric IP with user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GUC_PROXY_URI", + "weight": 1.0, + "description": "Has googleusercontent.com proxy URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rspamdbl", + "rules": [ + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocked", + "rules": [ + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocklistde", + "rules": [ + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime_types", + "rules": [ + { + "symbol": "MIME_DOUBLE_BAD_EXTENSION", + "weight": 3.0, + "description": "Bad extension cloaking", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TRACE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ARCHIVE_IN_ARCHIVE", + "weight": 5.0, + "description": "Archive within another archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_UNKNOWN", + "weight": 0.100000, + "description": "Missing or unknown content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_PGP", + "weight": -0.500000, + "description": "Message is encrypted with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_GOOD", + "weight": -0.100000, + "description": "Known content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOGUS_ENCRYPTED_AND_TEXT", + "weight": 10.0, + "description": "Bogus mix of encrypted and text/html payloads", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXTENSION", + "weight": 2.0, + "description": "Bad extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_EXE_IN_GEN_SPLIT_RAR", + "weight": 5.0, + "description": "EXE file in RAR archive with generic split extension (e.g. .001)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ENCRYPTED_ARCHIVE", + "weight": 2.0, + "description": "Encrypted archive in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD", + "weight": 1.0, + "description": "Known bad content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_SMIME", + "weight": -2.0, + "description": "Message is signed with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TYPES_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_UNICODE", + "weight": 2.0, + "description": "Filename with known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_PGP", + "weight": -2.0, + "description": "Message is signed with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_OBFUSCATED_ARCHIVE", + "weight": 2.0, + "description": "Archive has files with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_SMIME", + "weight": -0.500000, + "description": "Message is encrypted with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_ATTACHMENT", + "weight": 4.0, + "description": "Invalid attachment mime type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "antivirus", + "rules": [] + }, + { + "group": "spf", + "rules": [ + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "hfilter", + "rules": [ + { + "symbol": "HFILTER_URL_ONELINE", + "weight": 2.500000, + "description": "One line URL and text in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_3", + "weight": 2.0, + "description": "Helo host checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_1", + "weight": 0.500000, + "description": "Hostname checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_4", + "weight": 2.500000, + "description": "Helo host checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BAREIP", + "weight": 3.0, + "description": "Helo host is bare ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_4", + "weight": 2.500000, + "description": "Hostname checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_1", + "weight": 0.500000, + "description": "Helo host checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_5", + "weight": 3.0, + "description": "Helo host checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORESOLVE_MX", + "weight": 0.200000, + "description": "MX found in Helo and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_3", + "weight": 2.0, + "description": "Hostname checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_RCPT_BOUNCEMOREONE", + "weight": 1.500000, + "description": "Message from bounce and over 1 recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORES_A_OR_MX", + "weight": 1.500000, + "description": "FROM host no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_2", + "weight": 1.0, + "description": "Helo host checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BADIP", + "weight": 4.500000, + "description": "Helo host is very bad ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_2", + "weight": 1.0, + "description": "Hostname checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_5", + "weight": 3.0, + "description": "Hostname checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROM_BOUNCE", + "weight": 0.0, + "description": "Bounce message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_DNSFAIL", + "weight": 0.0, + "description": "PTR verification DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NOT_FQDN", + "weight": 2.0, + "description": "Helo not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORES_A_OR_MX", + "weight": 0.300000, + "description": "Helo no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORESOLVE_MX", + "weight": 0.500000, + "description": "MX found in FROM host and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NOT_FQDN", + "weight": 3.0, + "description": "FROM host not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_UNKNOWN", + "weight": 2.500000, + "description": "Unknown client hostname (PTR or FCrDNS verification failed)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_NONE", + "weight": 2.0, + "description": "Cannot resolve reverse DNS for sender's IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_IP_A", + "weight": 1.0, + "description": "Helo A IP != hostname IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_URL_ONLY", + "weight": 2.200000, + "description": "URL only in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "spamhaus", + "rules": [ + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ebl", + "rules": [ + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surblorg", + "rules": [ + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "uribl", + "rules": [ + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "external_services", + "rules": [] + }, + { + "group": "experimental", + "rules": [ + { + "symbol": "XM_UA_NO_VERSION", + "weight": 0.010000, + "description": "X-Mailer/User-Agent header has no version number", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "composite", + "rules": [ + { + "symbol": "SUSPICIOUS_AUTH_ORIGIN", + "weight": 0.0, + "description": "Message authenticated, but from a suspicios origin (potentially an injector)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_FORWARDING", + "weight": 0.0, + "description": "FORGED_RECIPIENTS & g:forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UNDISC_RCPTS_BULK", + "weight": 3.0, + "description": "Missing or undisclosed recipients with a bulk signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_URL_IN_SUSPICIOUS_MESSAGE", + "weight": 1.0, + "description": "Message contains redirector, anonymous or IPFS gateway URL and is marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_UNAUTH_PBL", + "weight": 2.0, + "description": "Relayed through Spamhaus PBL IP without sufficient authentication (possibly indicating an open relay)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISH_EMOTION", + "weight": 1.0, + "description": "Phish message with subject trying to address users emotion", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA_OR_FAIL", + "weight": 1.0, + "description": "No authenticating method SPF/DKIM/DMARC/ARC was successful", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL_ONLY", + "weight": 1.0, + "description": "Message only contains a redirector URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_VERP_SRS", + "weight": 0.0, + "description": "FORGED_SENDER & (ENVFROM_PRVS | ENVFROM_VERP)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ANON_DOMAIN", + "weight": 0.100000, + "description": "Contains one or more domains trying to disguise owner/destination", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS_MAILLIST", + "weight": 0.0, + "description": "Negate BROKEN_HEADERS when message comes via some mailing list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTOGEN_PHP_SPAMMY", + "weight": 1.0, + "description": "Message was generated by PHP script and contains some spam indicators", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple iOS Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "IP_SCORE_FREEMAIL", + "weight": 0.0, + "description": "Negate IP_SCORE when message comes from FreeMail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "VIOLATED_DIRECT_SPF", + "weight": 3.500000, + "description": "Has no Received (or no trusted received relays) and SPF policy fails or soft fails", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA", + "weight": 1.0, + "description": "Authenticating message via SPF/DKIM/DMARC/ARC not available", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM", + "weight": 2.0, + "description": "Reply-To is a Freemail address and it not match From header or SMTP From, also From is not another Freemail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_IN_OBFUSCATED_ARCHIVE", + "weight": 8.0, + "description": "Attachment with bad extension and archive that has filename with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAD_REP_POLICIES", + "weight": 0.100000, + "description": "Contains valid policies but are also marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID_ALLOWED", + "weight": 0.0, + "description": "MISSING_MID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_FAIL_FORWARDING", + "weight": 0.0, + "description": "g:forwarding & (R_SPF_SOFTFAIL | R_SPF_FAIL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID_ALLOWED", + "weight": 0.0, + "description": "INVALID_MSGID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_HI", + "weight": -1.0, + "description": "Sufficiently DKIM/ARC signed and received from IP with high trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_FORWARDING", + "weight": 0.0, + "description": "Forged sender, but message is forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_WITH_BAD_UNICODE", + "weight": 8.0, + "description": "Attachment with bad extension and filename that has known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_MED", + "weight": -0.500000, + "description": "Sufficiently DKIM/ARC signed and received from IP with medium trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_MIXED", + "weight": 0.0, + "description": "-R_DKIM_ALLOW & (R_DKIM_TEMPFAIL | R_DKIM_PERMFAIL | R_DKIM_REJECT)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE_NO_AUTH", + "weight": 1.0, + "description": "(AUTH_NA | AUTH_NA_OR_FAIL) & (BOUNCE | SUBJ_BOUNCE_WORDS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mid", + "rules": [ + { + "symbol": "MID_END_EQ_FROM_USER_PART", + "weight": 4.0, + "description": "Message-ID RHS (after @) and MIME from local part are the same", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_NO_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "fuzzy", + "rules": [ + { + "symbol": "FUZZY_DENIED", + "weight": 12.0, + "description": "Denied fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_PROB", + "weight": 5.0, + "description": "Probable fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_ENCRYPTION_REQUIRED", + "weight": 0.0, + "description": "Fuzzy encryption is required by a server", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_WHITE", + "weight": -2.100000, + "description": "Whitelisted fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_FORBIDDEN", + "weight": 0.0, + "description": "Fuzzy access denied", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_RATELIMITED", + "weight": 0.0, + "description": "Fuzzy rate limit is reached", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_UNKNOWN", + "weight": 5.0, + "description": "Generic fuzzy hash match, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_CALLBACK", + "weight": 0.0, + "description": "Fuzzy check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "senderscore", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "aliases", + "rules": [ + { + "symbol": "TAGGED_RCPT", + "weight": 0.0, + "description": "Recipient has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TAGGED_FROM", + "weight": 0.0, + "description": "From address has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTERNAL_MAIL", + "weight": 0.0, + "description": "Mail from local to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIASES_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_INBOUND", + "weight": 0.0, + "description": "Mail from external to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIAS_RESOLVED", + "weight": 0.0, + "description": "Address was resolved through aliases", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_OUTBOUND", + "weight": 0.0, + "description": "Mail from local to external domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "malware", + "rules": [ + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_FILENAME", + "weight": 9.0, + "description": "exe file in archive with clickbait filename", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_SUBJECT", + "weight": 9.0, + "description": "exe file in archive with clickbait subject", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISIDENTIFIED_RAR", + "weight": 4.0, + "description": "rar with wrong extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_ARCHIVE", + "weight": 1.500000, + "description": "exe file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_MISIDENTIFIED_RAR", + "weight": 5.0, + "description": "rar with wrong extension containing exe file", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_FILE_ARCHIVE_WITH_EXE", + "weight": 5.0, + "description": "single file container bearing executable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mailspike", + "rules": [ + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "compromised_hosts", + "rules": [ + { + "symbol": "URI_HIDDEN_PATH", + "weight": 1.0, + "description": "Message contains URI with a hidden path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XAW_SERVICE_ACCT", + "weight": 1.0, + "description": "Message originally from a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HIDDEN_SOURCE_OBJ", + "weight": 2.0, + "description": "UNIX hidden file/directory in path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_PHPMAILER_SIG", + "weight": 0.0, + "description": "PHPMailer signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WWW_DOT_DOMAIN", + "weight": 0.500000, + "description": "From/Sender/Reply-To or Envelope is @www.domain.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_SOURCE", + "weight": 0.0, + "description": "Has X-Source headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XAW", + "weight": 0.0, + "description": "Has X-Authentication-Warning header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PHP_SCRIPT", + "weight": 0.0, + "description": "Has X-PHP-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_SCRIPT_ROOT", + "weight": 1.0, + "description": "PHP Script executed by root UID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_XPS_PATTERN", + "weight": 0.0, + "description": "Message contains X-PHP-Script pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_AS", + "weight": 0.0, + "description": "Has X-Authenticated-Sender header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "COMPROMISED_ACCT_BULK", + "weight": 3.0, + "description": "Likely to be from a compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_EVAL", + "weight": 4.0, + "description": "Message sent using eval'd PHP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_POS", + "weight": 0.0, + "description": "Has X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_WP_URI", + "weight": 0.0, + "description": "Contains WordPress URIs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_FROM_INJECTOR", + "weight": 2.0, + "description": "Message is sent from a suspicios origin and showing signs of abuse, likely spam injected in compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_GMSV", + "weight": 0.0, + "description": "Has X-Get-Message-Sender-Via: header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Sender/From/Reply-To is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Envelope from is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_ANTIABUSE", + "weight": 0.0, + "description": "Has X-AntiAbuse headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WP_COMPROMISED", + "weight": 0.0, + "description": "URL that is pointing to a compromised WordPress installation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_WWW", + "weight": 0.500000, + "description": "Message-ID from www host", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "html", + "rules": [ + { + "symbol": "ZERO_FONT", + "weight": 1.0, + "description": "Zero sized font used", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_1", + "weight": 2.0, + "description": "Short HTML part (0..1K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_WHITE_ON_WHITE", + "weight": 4.0, + "description": "Message contains low contrast text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_2", + "weight": 1.0, + "description": "Short HTML part (1K..1.5K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_VISIBLE_CHECKS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_3", + "weight": 0.500000, + "description": "Short HTML part (1.5K..2K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_DATA_URI", + "weight": 0.0, + "description": "Has Data URI encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_IP", + "weight": 1.0, + "description": "HTML anchor points to an IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_EMPTY_IMAGE", + "weight": 2.0, + "description": "Message contains empty parts and image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MANY_INVISIBLE_PARTS", + "weight": 1.0, + "description": "Many parts are visually hidden", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SUSPICIOUS_IMAGES", + "weight": 5.0, + "description": "Message has high image to text ratio", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_HTTPS", + "weight": 0.500000, + "description": "The anchor text contains a distinct scheme compared to the target URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXT_CSS", + "weight": 1.0, + "description": "Message contains external CSS reference", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATA_URI_OBFU", + "weight": 2.0, + "description": "Uses Data URI encoding to obfuscate plain or HTML in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_META_REFRESH_URL", + "weight": 5.0, + "description": "Has HTML Meta refresh URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "subject", + "rules": [ + { + "symbol": "SUBJ_ALL_CAPS", + "weight": 3.0, + "description": "Subject contains mostly capital letters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LONG_SUBJ", + "weight": 3.0, + "description": "Subject is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_IN_SUBJECT", + "weight": 4.0, + "description": "Subject contains URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ungrouped", + "rules": [ + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLOCKLISTDE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_SHORT_PART", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UDF_COMPRESSION_500PLUS", + "weight": 9.0, + "description": "very well compressed img file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mua", + "rules": [ + { + "symbol": "FORGED_MUA_THEBAT_MSGID_UNKNOWN", + "weight": 3.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_KMAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from KMail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OPERA_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Opera Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OUTLOOK", + "weight": 3.0, + "description": "Forged Outlook MUA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY2", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY3", + "weight": 3.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY4", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY", + "weight": 5.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "whitelist", + "rules": [ + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blankspam", + "rules": [ + { + "symbol": "COMPLETELY_EMPTY", + "weight": 15.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SHORT_PART_BAD_HEADERS", + "weight": 7.0, + "description": "MISSING_ESSENTIAL_HEADERS & SINGLE_SHORT_PART", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_ESSENTIAL_HEADERS", + "weight": 7.0, + "description": "Common headers were entirely absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "content", + "rules": [ + { + "symbol": "PDF_TIMEOUT", + "weight": 0.0, + "description": "There is a PDF in the message that caused timeout in processing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_LONG_TRAILER", + "weight": 0.200000, + "description": "There is an PDF with a long trailer in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_JAVASCRIPT", + "weight": 0.100000, + "description": "There is an PDF with JavaScript in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_MANY_OBJECTS", + "weight": 0.0, + "description": "There is a PDF with too many objects in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_ENCRYPTED", + "weight": 0.300000, + "description": "There is an encrypted PDF in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_SUSPICIOUS", + "weight": 4.500000, + "description": "There is an PDF with suspicious properties in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "Message ID", + "rules": [ + { + "symbol": "MID_CONTAINS_TO", + "weight": 1.0, + "description": "Message-ID contains To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_MISSING_BRACKETS", + "weight": 0.500000, + "description": "Message-ID is missing <>'s", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_TO", + "weight": 1.0, + "description": "Message-ID RHS matches To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_NOT_FQDN", + "weight": 0.500000, + "description": "Message-ID RHS is not a fully-qualified domain name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROM", + "weight": 0.0, + "description": "Message-ID RHS matches From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_CONTAINS_FROM", + "weight": 1.0, + "description": "Message-ID contains From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_BARE_IP", + "weight": 2.0, + "description": "Message-ID RHS is a bare IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_IP_LITERAL", + "weight": 0.500000, + "description": "Message-ID RHS is an IP-literal", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROMTLD", + "weight": 0.0, + "description": "Message-ID RHS matches From domain tld", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers,mime", + "rules": [ + { + "symbol": "CHECK_TO_CC", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "scams", + "rules": [ + { + "symbol": "LEAKED_PASSWORD_SCAM_RE", + "weight": 0.0, + "description": "Contains BTC wallet address and malicious regexps", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_AFF", + "weight": 4.0, + "description": "Message exhibits strong characteristics of advance fee fraud (AFF a/k/a '419' spam) involving freemail addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTRODUCTION", + "weight": 2.0, + "description": "Sender introduces themselves", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_MDN", + "weight": 2.0, + "description": "Message delivery notification should go to freemail or disposable e-mail, but message was not sent from a freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BITCOIN_ADDR", + "weight": 0.0, + "description": "Message has a valid bitcoin wallet address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LEAKED_PASSWORD_SCAM", + "weight": 7.0, + "description": "Contains BTC wallet address and scam patterns", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "body", + "rules": [ + { + "symbol": "HAS_ATTACHMENT", + "weight": 0.0, + "description": "Message contains attachments", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_PARTS_DIFFER", + "weight": 1.0, + "description": "Text and HTML parts differ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + } +] diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go new file mode 100644 index 0000000..a0955ef --- /dev/null +++ b/pkg/analyzer/rspamd.go @@ -0,0 +1,174 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "math" + "regexp" + "strconv" + "strings" + + "git.happydns.org/happyDeliver/internal/model" +) + +// Default rspamd action thresholds (rspamd built-in defaults) +const ( + rspamdDefaultRejectThreshold float32 = 15 + rspamdDefaultAddHeaderThreshold float32 = 6 +) + +// RspamdAnalyzer analyzes rspamd results from email headers +type RspamdAnalyzer struct { + symbols map[string]string +} + +// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions +func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer { + return &RspamdAnalyzer{symbols: symbols} +} + +// AnalyzeRspamd extracts and analyzes rspamd results from email headers +func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult { + headers := email.GetRspamdHeaders() + if len(headers) == 0 { + return nil + } + + // Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report + _, hasSpamdResult := headers["X-Spamd-Result"] + _, hasRspamdScore := headers["X-Rspamd-Score"] + if !hasSpamdResult && !hasRspamdScore { + return nil + } + + result := &model.RspamdResult{ + Symbols: make(map[string]model.SpamTestDetail), + } + + // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) + // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." + if spamdResult, ok := headers["X-Spamd-Result"]; ok { + report := strings.ReplaceAll(spamdResult, "; ", ";\n") + result.Report = &report + a.parseSpamdResult(spamdResult, result) + } + + // Parse X-Rspamd-Score as override/fallback for score + if scoreHeader, ok := headers["X-Rspamd-Score"]; ok { + if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { + result.Score = float32(score) + } + } + + // Parse X-Rspamd-Server + if serverHeader, ok := headers["X-Rspamd-Server"]; ok { + server := strings.TrimSpace(serverHeader) + result.Server = &server + } + + // Populate symbol descriptions from the lookup map + if a.symbols != nil { + for name, sym := range result.Symbols { + if desc, ok := a.symbols[name]; ok { + sym.Description = &desc + result.Symbols[name] = sym + } + } + } + + // Derive IsSpam from score vs reject threshold. + if result.Threshold > 0 { + result.IsSpam = result.Score >= result.Threshold + } else { + result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold + } + + return result +} + +// parseSpamdResult parses the X-Spamd-Result header +// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." +func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) { + // Extract score and threshold from the first line + // e.g. "default: False [-3.91 / 15.00]" + scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`) + if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 { + if score, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Score = float32(score) + } + if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil { + result.Threshold = float32(threshold) + + // No threshold? use default AddHeaderThreshold + if result.Threshold <= 0 { + result.Threshold = rspamdDefaultAddHeaderThreshold + } + } + } + + // Parse is_spam from header (before we may get action from X-Rspamd-Action) + firstLine := strings.SplitN(header, ";", 2)[0] + if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") { + result.IsSpam = true + } + + // Parse symbols: SYMBOL(score)[params] + // Each symbol entry is separated by ";", so within each part we use a + // greedy match to capture params that may contain nested brackets. + symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`) + for _, part := range strings.Split(header, ";") { + part = strings.TrimSpace(part) + matches := symbolRe.FindStringSubmatch(part) + if len(matches) > 2 { + name := matches[1] + score, _ := strconv.ParseFloat(matches[2], 64) + sym := model.SpamTestDetail{ + Name: name, + Score: float32(score), + } + if len(matches) > 3 && matches[3] != "" { + params := matches[3] + sym.Params = ¶ms + } + result.Symbols[name] = sym + } + } +} + +// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale) +func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) { + if result == nil { + return 100, "" // rspamd not installed + } + + threshold := result.Threshold + percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold)))) + + if percentage > 100 { + return 100, "A+" + } else if percentage < 0 { + return 0, "F" + } + + // Linear scale between 0 and threshold + return percentage, ScoreToGrade(percentage) +} diff --git a/pkg/analyzer/rspamd_symbols.go b/pkg/analyzer/rspamd_symbols.go new file mode 100644 index 0000000..e50a452 --- /dev/null +++ b/pkg/analyzer/rspamd_symbols.go @@ -0,0 +1,105 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + _ "embed" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" +) + +//go:embed rspamd-symbols.json +var embeddedRspamdSymbols []byte + +// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON. +type rspamdSymbolGroup struct { + Group string `json:"group"` + Rules []rspamdSymbolEntry `json:"rules"` +} + +// rspamdSymbolEntry represents a single rspamd symbol entry. +type rspamdSymbolEntry struct { + Symbol string `json:"symbol"` + Description string `json:"description"` + Weight float64 `json:"weight"` +} + +// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map. +func parseRspamdSymbolsJSON(data []byte) map[string]string { + var groups []rspamdSymbolGroup + if err := json.Unmarshal(data, &groups); err != nil { + log.Printf("Failed to parse rspamd symbols JSON: %v", err) + return nil + } + + symbols := make(map[string]string, len(groups)*10) + for _, g := range groups { + for _, r := range g.Rules { + if r.Description != "" { + symbols[r.Symbol] = r.Description + } + } + } + return symbols +} + +// LoadRspamdSymbols loads rspamd symbol descriptions. +// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error. +func LoadRspamdSymbols(apiURL string) map[string]string { + if apiURL != "" { + if symbols := fetchRspamdSymbols(apiURL); symbols != nil { + return symbols + } + log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL) + } + return parseRspamdSymbolsJSON(embeddedRspamdSymbols) +} + +// fetchRspamdSymbols fetches symbol descriptions from the rspamd API. +func fetchRspamdSymbols(apiURL string) map[string]string { + url := strings.TrimRight(apiURL, "/") + "/symbols" + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + log.Printf("Error fetching rspamd symbols: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("rspamd API returned status %d", resp.StatusCode) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Error reading rspamd symbols response: %v", err) + return nil + } + + return parseRspamdSymbolsJSON(body) +} diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..9804f1d --- /dev/null +++ b/pkg/analyzer/rspamd_test.go @@ -0,0 +1,414 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "net/mail" + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer(nil) + email := &EmailMessage{Header: make(mail.Header)} + + result := analyzer.AnalyzeRspamd(email) + + if result != nil { + t.Errorf("Expected nil for email without rspamd headers, got %+v", result) + } +} + +func TestParseSpamdResult(t *testing.T) { + tests := []struct { + name string + header string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedSymbols map[string]float32 + expectedSymParams map[string]string + }{ + { + name: "Clean email negative score", + header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]", + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "DATE_IN_PAST": 0.10, + "ALL_TRUSTED": -1.00, + }, + expectedSymParams: map[string]string{ + "ALL_TRUSTED": "trusted", + }, + }, + { + name: "Spam email True flag", + header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)", + expectedScore: 16.50, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{ + "BAYES_99": 5.00, + "SPOOFED_SENDER": 3.50, + }, + expectedSymParams: map[string]string{ + "BAYES_99": "1.00", + }, + }, + { + name: "Zero threshold uses default", + header: "default: False [1.00 / 0.00]", + expectedScore: 1.00, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{}, + }, + { + name: "Symbol without params", + header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)", + expectedScore: 2.00, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "MISSING_DATE": 1.00, + }, + }, + { + name: "Case-insensitive true flag", + header: "default: true [8.00 / 6.00]", + expectedScore: 8.00, + expectedThreshold: 6.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{}, + }, + { + name: "Zero threshold with symbols containing nested brackets in params", + header: "default: False [0.90 / 0.00];\n" + + "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" + + "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" + + "\tMIME_TRACE(0.00)[0:+,1:+,2:~]", + expectedScore: 0.90, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "ARC_REJECT": 1.00, + "MIME_GOOD": -0.10, + "MIME_TRACE": 0.00, + }, + expectedSymParams: map[string]string{ + "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}", + "MIME_GOOD": "multipart/alternative,text/plain", + "MIME_TRACE": "0:+,1:+,2:~", + }, + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &model.RspamdResult{ + Symbols: make(map[string]model.SpamTestDetail), + } + analyzer.parseSpamdResult(tt.header, result) + + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + for symName, expectedScore := range tt.expectedSymbols { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found", symName) + continue + } + if sym.Score != expectedScore { + t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore) + } + } + for symName, expectedParam := range tt.expectedSymParams { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found for params check", symName) + continue + } + if sym.Params == nil { + t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam) + } else if *sym.Params != expectedParam { + t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam) + } + } + }) + } +} + +func TestAnalyzeRspamd(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedServer *string + expectedSymCount int + }{ + { + name: "Full headers clean email", + headers: map[string]string{ + "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]", + "X-Rspamd-Score": "-3.91", + "X-Rspamd-Server": "mail.example.com", + }, + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedServer: func() *string { s := "mail.example.com"; return &s }(), + expectedSymCount: 1, + }, + { + name: "X-Rspamd-Score overrides spamd result score", + headers: map[string]string{ + "X-Spamd-Result": "default: False [2.00 / 15.00]", + "X-Rspamd-Score": "3.50", + }, + expectedScore: 3.50, + expectedThreshold: 15.00, + expectedIsSpam: false, + }, + { + name: "Spam email above threshold", + headers: map[string]string{ + "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)", + "X-Rspamd-Score": "16.00", + }, + expectedScore: 16.00, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymCount: 1, + }, + { + name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold", + headers: map[string]string{ + "X-Rspamd-Score": "2.00", + }, + expectedScore: 2.00, + expectedIsSpam: false, + }, + { + name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold", + headers: map[string]string{ + "X-Rspamd-Score": "7.00", + }, + expectedScore: 7.00, + expectedIsSpam: true, + }, + { + name: "Server header is trimmed", + headers: map[string]string{ + "X-Rspamd-Score": "1.00", + "X-Rspamd-Server": " rspamd-01 ", + }, + expectedScore: 1.00, + expectedServer: func() *string { s := "rspamd-01"; return &s }(), + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{Header: make(mail.Header)} + for k, v := range tt.headers { + email.Header[k] = []string{v} + } + + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + if tt.expectedServer != nil { + if result.Server == nil { + t.Errorf("Server = nil, want %q", *tt.expectedServer) + } else if *result.Server != *tt.expectedServer { + t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer) + } + } + if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount { + t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount) + } + }) + } +} + +func TestCalculateRspamdScore(t *testing.T) { + tests := []struct { + name string + result *model.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &model.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &model.RspamdResult{ + Score: 0, + Threshold: 15.00, + }, + // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A" + expectedScore: 100, + expectedGrade: "A", + }, + { + name: "Score at threshold (half of 2*threshold)", + result: &model.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &model.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &model.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, grade := analyzer.CalculateRspamdScore(tt.result) + + if score != tt.expectedScore { + t.Errorf("Score = %d, want %d", score, tt.expectedScore) + } + if tt.expectedGrade != "" && grade != tt.expectedGrade { + t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade) + } + }) + } +} + +const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00]; + BAYES_HAM(-3.00)[99%]; + RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from]; + R_DKIM_ALLOW(-0.20)[example.com:s=dkim]; + FROM_HAS_DN(0.00)[]; + MIME_GOOD(-0.10)[text/plain]; +X-Rspamd-Score: -3.91 +X-Rspamd-Server: rspamd-01.example.com +Date: Mon, 09 Mar 2026 10:00:00 +0000 +From: sender@example.com +To: test@happydomain.org +Subject: Test email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +Hello world` + +func TestAnalyzeRspamdRealEmail(t *testing.T) { + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + analyzer := NewRspamdAnalyzer(nil) + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.IsSpam { + t.Error("Expected IsSpam=false") + } + if result.Score != -3.91 { + t.Errorf("Score = %v, want -3.91", result.Score) + } + if result.Threshold != 15.00 { + t.Errorf("Threshold = %v, want 15.00", result.Threshold) + } + if result.Server == nil || *result.Server != "rspamd-01.example.com" { + t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server) + } + + expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"} + for _, sym := range expectedSymbols { + if _, ok := result.Symbols[sym]; !ok { + t.Errorf("Symbol %s not found", sym) + } + } + + score, _ := analyzer.CalculateRspamdScore(result) + if score != 100 { + t.Errorf("CalculateRspamdScore = %d, want 100", score) + } +} + diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 03ab870..0baeab7 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -22,524 +22,80 @@ package analyzer import ( - "fmt" - "strings" - "time" - - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) -// DeliverabilityScorer aggregates all analysis results and computes overall score -type DeliverabilityScorer struct{} - -// NewDeliverabilityScorer creates a new deliverability scorer -func NewDeliverabilityScorer() *DeliverabilityScorer { - return &DeliverabilityScorer{} -} - -// ScoringResult represents the complete scoring result -type ScoringResult struct { - OverallScore float32 - Rating string // Excellent, Good, Fair, Poor, Critical - AuthScore float32 - SpamScore float32 - BlacklistScore float32 - ContentScore float32 - HeaderScore float32 - Recommendations []string - CategoryBreakdown map[string]CategoryScore -} - -// CategoryScore represents score breakdown for a category -type CategoryScore struct { - Score float32 - MaxScore float32 - Percentage float32 - Status string // Pass, Warn, Fail -} - -// CalculateScore computes the overall deliverability score from all analyzers -func (s *DeliverabilityScorer) CalculateScore( - authResults *api.AuthenticationResults, - spamResult *SpamAssassinResult, - rblResults *RBLResults, - contentResults *ContentResults, - email *EmailMessage, -) *ScoringResult { - result := &ScoringResult{ - CategoryBreakdown: make(map[string]CategoryScore), - Recommendations: []string{}, - } - - // Calculate individual scores - result.AuthScore = s.GetAuthenticationScore(authResults) - - spamAnalyzer := NewSpamAssassinAnalyzer() - result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) - - rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs) - result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults) - - contentAnalyzer := NewContentAnalyzer(10 * time.Second) - result.ContentScore = contentAnalyzer.GetContentScore(contentResults) - - // Calculate header quality score - result.HeaderScore = s.calculateHeaderScore(email) - - // Calculate overall score (out of 10) - result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - - // Ensure score is within bounds - if result.OverallScore > 10.0 { - result.OverallScore = 10.0 - } - if result.OverallScore < 0.0 { - result.OverallScore = 0.0 - } - - // Determine rating - result.Rating = s.determineRating(result.OverallScore) - - // Build category breakdown - result.CategoryBreakdown["Authentication"] = CategoryScore{ - Score: result.AuthScore, - MaxScore: 3.0, - Percentage: (result.AuthScore / 3.0) * 100, - Status: s.getCategoryStatus(result.AuthScore, 3.0), - } - - result.CategoryBreakdown["Spam Filters"] = CategoryScore{ - Score: result.SpamScore, - MaxScore: 2.0, - Percentage: (result.SpamScore / 2.0) * 100, - Status: s.getCategoryStatus(result.SpamScore, 2.0), - } - - result.CategoryBreakdown["Blacklists"] = CategoryScore{ - Score: result.BlacklistScore, - MaxScore: 2.0, - Percentage: (result.BlacklistScore / 2.0) * 100, - Status: s.getCategoryStatus(result.BlacklistScore, 2.0), - } - - result.CategoryBreakdown["Content Quality"] = CategoryScore{ - Score: result.ContentScore, - MaxScore: 2.0, - Percentage: (result.ContentScore / 2.0) * 100, - Status: s.getCategoryStatus(result.ContentScore, 2.0), - } - - result.CategoryBreakdown["Email Structure"] = CategoryScore{ - Score: result.HeaderScore, - MaxScore: 1.0, - Percentage: (result.HeaderScore / 1.0) * 100, - Status: s.getCategoryStatus(result.HeaderScore, 1.0), - } - - // Generate recommendations - result.Recommendations = s.generateRecommendations(result) - - return result -} - -// calculateHeaderScore evaluates email structural quality (0-1 point) -func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { - if email == nil { - return 0.0 - } - - score := float32(0.0) - requiredHeaders := 0 - presentHeaders := 0 - - // Check required headers (RFC 5322) - headers := map[string]bool{ - "From": false, - "Date": false, - "Message-ID": false, - } - - for header := range headers { - requiredHeaders++ - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - headers[header] = true - presentHeaders++ - } - } - - // Score based on required headers (0.4 points) - if presentHeaders == requiredHeaders { - score += 0.4 - } else { - score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) - } - - // Check recommended headers (0.3 points) - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - recommendedPresent := 0 - for _, header := range recommendedHeaders { - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - recommendedPresent++ - } - } - score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - - // Check for proper MIME structure (0.2 points) - if len(email.Parts) > 0 { - score += 0.2 - } - - // Check Message-ID format (0.1 points) - if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { - if s.isValidMessageID(messageID) { - score += 0.1 - } - } - - // Ensure score doesn't exceed 1.0 - if score > 1.0 { - score = 1.0 - } - - return score -} - -// isValidMessageID checks if a Message-ID has proper format -func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { - // Basic check: should be in format <...@...> - if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { - return false - } - - // Remove angle brackets - messageID = strings.TrimPrefix(messageID, "<") - messageID = strings.TrimSuffix(messageID, ">") - - // Should contain @ symbol - if !strings.Contains(messageID, "@") { - return false - } - - parts := strings.Split(messageID, "@") - if len(parts) != 2 { - return false - } - - // Both parts should be non-empty - return len(parts[0]) > 0 && len(parts[1]) > 0 -} - -// determineRating determines the rating based on overall score -func (s *DeliverabilityScorer) determineRating(score float32) string { +// ScoreToGrade converts a percentage score (0-100) to a letter grade +func ScoreToGrade(score int) string { switch { - case score >= 9.0: - return "Excellent" - case score >= 7.0: - return "Good" - case score >= 5.0: - return "Fair" - case score >= 3.0: - return "Poor" + case score > 100: + return "A+" + case score >= 95: + return "A" + case score >= 85: + return "B" + case score >= 75: + return "C" + case score >= 65: + return "D" + case score >= 50: + return "E" default: - return "Critical" + return "F" } } -// getCategoryStatus determines status for a category -func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string { - percentage := (score / maxScore) * 100 - +// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation +func ScoreToGradeKind(score int) string { switch { - case percentage >= 80.0: - return "Pass" - case percentage >= 50.0: - return "Warn" + case score > 100: + return "A+" + case score >= 90: + return "A" + case score >= 80: + return "B" + case score >= 60: + return "C" + case score >= 45: + return "D" + case score >= 30: + return "E" default: - return "Fail" + return "F" } } -// generateRecommendations creates actionable recommendations based on scores -func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { - var recommendations []string - - // Authentication recommendations - if result.AuthScore < 2.0 { - recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") - } else if result.AuthScore < 3.0 { - recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") - } - - // Spam recommendations - if result.SpamScore < 1.0 { - recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") - } else if result.SpamScore < 1.5 { - recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") - } - - // Blacklist recommendations - if result.BlacklistScore < 1.0 { - recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") - } else if result.BlacklistScore < 2.0 { - recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") - } - - // Content recommendations - if result.ContentScore < 1.0 { - recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") - } else if result.ContentScore < 1.5 { - recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") - } - - // Header recommendations - if result.HeaderScore < 0.5 { - recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") - } else if result.HeaderScore < 1.0 { - recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") - } - - // Overall recommendations based on rating - if result.Rating == "Excellent" { - recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices") - } else if result.Rating == "Critical" { - recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam") - } - - return recommendations +// ScoreToReportGrade converts a percentage score to an model.ReportGrade +func ScoreToReportGrade(score int) model.ReportGrade { + return model.ReportGrade(ScoreToGrade(score)) } -// GenerateHeaderChecks creates checks for email header quality -func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check { - var checks []api.Check - - if email == nil { - return checks +// gradeRank returns a numeric rank for a grade (lower = worse) +func gradeRank(grade string) int { + switch grade { + case "A++": + return 7 + case "A+": + return 6 + case "A": + return 5 + case "B": + return 4 + case "C": + return 3 + case "D": + return 2 + case "E": + return 1 + default: + return 0 } - - // Required headers check - checks = append(checks, s.generateRequiredHeadersCheck(email)) - - // Recommended headers check - checks = append(checks, s.generateRecommendedHeadersCheck(email)) - - // Message-ID check - checks = append(checks, s.generateMessageIDCheck(email)) - - // MIME structure check - checks = append(checks, s.generateMIMEStructureCheck(email)) - - return checks } -// generateRequiredHeadersCheck checks for required RFC 5322 headers -func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Required Headers", +// MinGrade returns the minimal (worse) grade between the two given grades +func MinGrade(a, b string) string { + if gradeRank(a) <= gradeRank(b) { + return a } - - requiredHeaders := []string{"From", "Date", "Message-ID"} - missing := []string{} - - for _, header := range requiredHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 0.4 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "All required headers are present" - check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") - } else { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } - - return check -} - -// generateRecommendedHeadersCheck checks for recommended headers -func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Recommended Headers", - } - - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - missing := []string{} - - for _, header := range recommendedHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "All recommended headers are present" - check.Advice = api.PtrTo("Your email includes all recommended headers") - } else if len(missing) < len(recommendedHeaders) { - check.Status = api.CheckStatusWarn - check.Score = 0.15 - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } else { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Missing all recommended headers" - check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") - } - - return check -} - -// generateMessageIDCheck validates Message-ID header -func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Message-ID Format", - } - - messageID := email.GetHeaderValue("Message-ID") - - if messageID == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Message = "Message-ID header is missing" - check.Advice = api.PtrTo("Add a unique Message-ID header to your email") - } else if !s.isValidMessageID(messageID) { - check.Status = api.CheckStatusWarn - check.Score = 0.05 - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Message-ID format is invalid" - check.Advice = api.PtrTo("Use proper Message-ID format: ") - check.Details = &messageID - } else { - check.Status = api.CheckStatusPass - check.Score = 0.1 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "Message-ID is properly formatted" - check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") - check.Details = &messageID - } - - return check -} - -// generateMIMEStructureCheck validates MIME structure -func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "MIME Structure", - } - - if len(email.Parts) == 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = "No MIME parts detected" - check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") - } else { - check.Status = api.CheckStatusPass - check.Score = 0.2 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) - check.Advice = api.PtrTo("Your email has proper MIME structure") - - // Add details about parts - partTypes := []string{} - for _, part := range email.Parts { - if part.ContentType != "" { - partTypes = append(partTypes, part.ContentType) - } - } - if len(partTypes) > 0 { - details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) - check.Details = &details - } - } - - return check -} - -// GetScoreSummary generates a human-readable summary of the score -func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { - var summary strings.Builder - - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating)) - summary.WriteString("Category Breakdown:\n") - summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n", - result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) - summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n", - result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) - summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n", - result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) - summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n", - result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) - summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n", - result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) - - if len(result.Recommendations) > 0 { - summary.WriteString("\nRecommendations:\n") - for _, rec := range result.Recommendations { - summary.WriteString(fmt.Sprintf(" %s\n", rec)) - } - } - - return summary.String() -} - -// GetAuthenticationScore calculates the authentication score (0-3 points) -func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { - var score float32 = 0.0 - - // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 1.0 - case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 0.5 - } - } - - // DKIM: 1 point for at least one pass - if results.Dkim != nil && len(*results.Dkim) > 0 { - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - score += 1.0 - break - } - } - } - - // DMARC: 1 point for pass - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 1.0 - } - } - - // Cap at 3 points maximum - if score > 3.0 { - score = 3.0 - } - - return score + return b } diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go deleted file mode 100644 index b28182d..0000000 --- a/pkg/analyzer/scoring_test.go +++ /dev/null @@ -1,762 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "net/mail" - "net/textproto" - "strings" - "testing" - - "git.happydns.org/happyDeliver/internal/api" -) - -func TestNewDeliverabilityScorer(t *testing.T) { - scorer := NewDeliverabilityScorer() - if scorer == nil { - t.Fatal("Expected scorer, got nil") - } -} - -func TestIsValidMessageID(t *testing.T) { - tests := []struct { - name string - messageID string - expected bool - }{ - { - name: "Valid Message-ID", - messageID: "", - expected: true, - }, - { - name: "Valid with UUID", - messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>", - expected: true, - }, - { - name: "Missing angle brackets", - messageID: "abc123@example.com", - expected: false, - }, - { - name: "Missing @ symbol", - messageID: "", - expected: false, - }, - { - name: "Multiple @ symbols", - messageID: "", - expected: false, - }, - { - name: "Empty local part", - messageID: "<@example.com>", - expected: false, - }, - { - name: "Empty domain part", - messageID: "", - expected: false, - }, - { - name: "Empty", - messageID: "", - expected: false, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.isValidMessageID(tt.messageID) - if result != tt.expected { - t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) - } - }) - } -} - -func TestCalculateHeaderScore(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minScore float32 - maxScore float32 - }{ - { - name: "Nil email", - email: nil, - minScore: 0.0, - maxScore: 0.0, - }, - { - name: "Perfect headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.7, - maxScore: 1.0, - }, - { - name: "Missing required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Subject": "Test", - }), - }, - minScore: 0.0, - maxScore: 0.4, - }, - { - name: "Required only, no recommended", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.4, - maxScore: 0.8, - }, - { - name: "Invalid Message-ID format", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "invalid-message-id", - "Subject": "Test", - "To": "recipient@example.com", - "Reply-To": "reply@example.com", - }), - MessageID: "invalid-message-id", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.7, - maxScore: 1.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := scorer.calculateHeaderScore(tt.email) - if score < tt.minScore || score > tt.maxScore { - t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - }) - } -} - -func TestDetermineRating(t *testing.T) { - tests := []struct { - name string - score float32 - expected string - }{ - {name: "Excellent - 10.0", score: 10.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 9.5, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 9.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 8.5, expected: "Good"}, - {name: "Good - 7.0", score: 7.0, expected: "Good"}, - {name: "Fair - 6.5", score: 6.5, expected: "Fair"}, - {name: "Fair - 5.0", score: 5.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 4.5, expected: "Poor"}, - {name: "Poor - 3.0", score: 3.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 2.5, expected: "Critical"}, - {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.determineRating(tt.score) - if result != tt.expected { - t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected) - } - }) - } -} - -func TestGetCategoryStatus(t *testing.T) { - tests := []struct { - name string - score float32 - maxScore float32 - expected string - }{ - {name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"}, - {name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"}, - {name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"}, - {name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"}, - {name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.getCategoryStatus(tt.score, tt.maxScore) - if result != tt.expected { - t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected) - } - }) - } -} - -func TestCalculateScore(t *testing.T) { - tests := []struct { - name string - authResults *api.AuthenticationResults - spamResult *SpamAssassinResult - rblResults *RBLResults - contentResults *ContentResults - email *EmailMessage - minScore float32 - maxScore float32 - expectedRating string - }{ - { - name: "Perfect email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{Result: api.AuthResultResultPass}, - }, - spamResult: &SpamAssassinResult{ - Score: -1.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextPlainRatio: 0.8, - ImageTextRatio: 3.0, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 9.0, - maxScore: 10.0, - expectedRating: "Excellent", - }, - { - name: "Poor email - auth issues", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultFail}, - Dkim: &[]api.AuthResult{}, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 8.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - { - IP: "192.0.2.1", - RBL: "zen.spamhaus.org", - Listed: true, - }, - }, - ListedCount: 1, - }, - contentResults: &ContentResults{ - HTMLValid: false, - Links: []LinkCheck{{Valid: true, Status: 404}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - minScore: 0.0, - maxScore: 5.0, - expectedRating: "Poor", - }, - { - name: "Average email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 4.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 6.0, - maxScore: 9.0, - expectedRating: "Good", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.CalculateScore( - tt.authResults, - tt.spamResult, - tt.rblResults, - tt.contentResults, - tt.email, - ) - - if result == nil { - t.Fatal("Expected result, got nil") - } - - // Check overall score - if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore { - t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore) - } - - // Check rating - if result.Rating != tt.expectedRating { - t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) - } - - // Verify score is within bounds - if result.OverallScore < 0.0 || result.OverallScore > 10.0 { - t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore) - } - - // Verify category breakdown exists - if len(result.CategoryBreakdown) != 5 { - t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown)) - } - - // Verify recommendations exist - if len(result.Recommendations) == 0 && result.Rating != "Excellent" { - t.Error("Expected recommendations for non-excellent rating") - } - - // Verify category scores add up to overall score - totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 { - t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)", - totalCategoryScore, result.OverallScore) - } - }) - } -} - -func TestGenerateRecommendations(t *testing.T) { - tests := []struct { - name string - result *ScoringResult - expectedMinCount int - shouldContainKeyword string - }{ - { - name: "Excellent - minimal recommendations", - result: &ScoringResult{ - OverallScore: 9.5, - Rating: "Excellent", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 2.0, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "Excellent", - }, - { - name: "Critical - many recommendations", - result: &ScoringResult{ - OverallScore: 1.0, - Rating: "Critical", - AuthScore: 0.5, - SpamScore: 0.0, - BlacklistScore: 0.0, - ContentScore: 0.3, - HeaderScore: 0.2, - }, - expectedMinCount: 5, - shouldContainKeyword: "Critical", - }, - { - name: "Poor authentication", - result: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "authentication", - }, - { - name: "Blacklist issues", - result: &ScoringResult{ - OverallScore: 4.0, - Rating: "Poor", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 0.5, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "blacklist", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recommendations := scorer.generateRecommendations(tt.result) - - if len(recommendations) < tt.expectedMinCount { - t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount) - } - - // Check if expected keyword appears in any recommendation - found := false - for _, rec := range recommendations { - if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) { - found = true - break - } - } - - if !found { - t.Errorf("No recommendation contains keyword %q. Recommendations: %v", - tt.shouldContainKeyword, recommendations) - } - }) - } -} - -func TestGenerateRequiredHeadersCheck(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "All required headers present", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - From: &mail.Address{Address: "sender@example.com"}, - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, - }, - { - name: "Missing all required headers", - email: &EmailMessage{ - Header: make(mail.Header), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "Missing some required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := scorer.generateRequiredHeadersCheck(tt.email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMessageIDCheck(t *testing.T) { - tests := []struct { - name string - messageID string - expectedStatus api.CheckStatus - }{ - { - name: "Valid Message-ID", - messageID: "", - expectedStatus: api.CheckStatusPass, - }, - { - name: "Invalid Message-ID format", - messageID: "invalid-message-id", - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Missing Message-ID", - messageID: "", - expectedStatus: api.CheckStatusFail, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Message-ID": tt.messageID, - }), - } - - check := scorer.generateMessageIDCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMIMEStructureCheck(t *testing.T) { - tests := []struct { - name string - parts []MessagePart - expectedStatus api.CheckStatus - }{ - { - name: "With MIME parts", - parts: []MessagePart{ - {ContentType: "text/plain", Content: "test"}, - {ContentType: "text/html", Content: "

test

"}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No MIME parts", - parts: []MessagePart{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - Parts: tt.parts, - } - - check := scorer.generateMIMEStructureCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateHeaderChecks(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minChecks int - }{ - { - name: "Nil email", - email: nil, - minChecks: 0, - }, - { - name: "Complete email", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minChecks: 4, // Required, Recommended, Message-ID, MIME - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := scorer.GenerateHeaderChecks(tt.email) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Headers category - for _, check := range checks { - if check.Category != api.Headers { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) - } - } - }) - } -} - -func TestGetScoreSummary(t *testing.T) { - result := &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - Recommendations: []string{ - "Improve content quality", - "Add more headers", - }, - } - - scorer := NewDeliverabilityScorer() - summary := scorer.GetScoreSummary(result) - - // Check that summary contains key information - if !strings.Contains(summary, "8.5") { - t.Error("Summary should contain overall score") - } - if !strings.Contains(summary, "Good") { - t.Error("Summary should contain rating") - } - if !strings.Contains(summary, "Authentication") { - t.Error("Summary should contain category names") - } - if !strings.Contains(summary, "Recommendations") { - t.Error("Summary should contain recommendations section") - } -} - -// Helper function to create mail.Header with specific fields -func createHeaderWithFields(fields map[string]string) mail.Header { - header := make(mail.Header) - for key, value := range fields { - if value != "" { - // Use canonical MIME header key format - canonicalKey := textproto.CanonicalMIMEHeaderKey(key) - header[canonicalKey] = []string{value} - } - } - return header -} diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 00cab21..96f60dd 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -22,12 +22,13 @@ package analyzer import ( - "fmt" + "math" "regexp" "strconv" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // SpamAssassinAnalyzer analyzes SpamAssassin results from email headers @@ -38,44 +39,34 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { return &SpamAssassinAnalyzer{} } -// SpamAssassinResult represents parsed SpamAssassin results -type SpamAssassinResult struct { - IsSpam bool - Score float64 - RequiredScore float64 - Tests []string - TestDetails map[string]SpamTestDetail - Version string - RawReport string -} - -// SpamTestDetail contains details about a specific spam test -type SpamTestDetail struct { - Name string - Score float64 - Description string -} - // AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers -func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult { +func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult { headers := email.GetSpamAssassinHeaders() if len(headers) == 0 { return nil } - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + // Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report + _, hasStatus := headers["X-Spam-Status"] + _, hasScore := headers["X-Spam-Score"] + _, hasFlag := headers["X-Spam-Flag"] + if !hasStatus && !hasScore && !hasFlag { + return nil + } + + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } // Parse X-Spam-Status header - if statusHeader, ok := headers["X-Spam-Status"]; ok { + if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" { a.parseSpamStatus(statusHeader, result) } // Parse X-Spam-Score header (as fallback if not in X-Spam-Status) if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 { if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { - result.Score = score + result.Score = float32(score) } } @@ -86,13 +77,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // Parse X-Spam-Report header for detailed test results if reportHeader, ok := headers["X-Spam-Report"]; ok { - result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1) + result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1)) a.parseSpamReport(reportHeader, result) } // Parse X-Spam-Checker-Version if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok { - result.Version = strings.TrimSpace(versionHeader) + result.Version = utils.PtrTo(strings.TrimSpace(versionHeader)) } return result @@ -100,7 +91,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // parseSpamStatus parses the X-Spam-Status header // Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no -func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) { +func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) { // Check if spam (first word) parts := strings.SplitN(header, ",", 2) if len(parts) > 0 { @@ -112,7 +103,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`) if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 { if score, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.Score = score + result.Score = float32(score) } } @@ -120,19 +111,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`) if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 { if required, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.RequiredScore = required + result.RequiredScore = float32(required) } } // Extract tests - testsRe := regexp.MustCompile(`tests=([^\s]+)`) + testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`) if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 { testsStr := matches[1] // Tests can be comma or space separated tests := strings.FieldsFunc(testsStr, func(r rune) bool { return r == ',' || r == ' ' }) - result.Tests = tests + result.Tests = &tests } } @@ -140,17 +131,20 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass // Format varies, but typically: // * 1.5 TEST_NAME Description of test // * 0.0 TEST_NAME2 Description -// Note: mail.Header.Get() joins continuation lines, so newlines are removed. -// We split on '*' to separate individual tests. -func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) { - // The report header has been joined by mail.Header.Get(), so we split on '*' - // Each segment starting with '*' is either a test line or continuation +// Multiline descriptions continue on lines starting with * but without score: +// * 0.0 TEST_NAME Description line 1 +// * continuation line 2 +// * continuation line 3 +func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) { segments := strings.Split(report, "*") // Regex to match test lines: score TEST_NAME Description // Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description" testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + var currentTestName string + var currentDescription strings.Builder + for _, segment := range segments { segment = strings.TrimSpace(segment) if segment == "" { @@ -160,186 +154,76 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass // Try to match as a test line matches := testRe.FindStringSubmatch(segment) if len(matches) > 3 { + // Save previous test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := model.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } + + // Start new test testName := matches[2] score, _ := strconv.ParseFloat(matches[1], 64) description := strings.TrimSpace(matches[3]) - detail := SpamTestDetail{ - Name: testName, - Score: score, - Description: description, + currentTestName = testName + currentDescription.Reset() + currentDescription.WriteString(description) + + // Initialize with score + result.TestDetails[testName] = model.SpamTestDetail{ + Name: testName, + Score: float32(score), } - result.TestDetails[testName] = detail + } else if currentTestName != "" { + // This is a continuation line for the current test + // Add a space before appending to ensure proper word separation + if currentDescription.Len() > 0 { + currentDescription.WriteString(" ") + } + currentDescription.WriteString(segment) } } + + // Save the last test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := model.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points) -// Scoring: -// - Score <= 0: 2 points (excellent) -// - Score < required: 1.5 points (good) -// - Score slightly above required (< 2x): 1 point (borderline) -// - Score moderately high (< 3x required): 0.5 points (poor) -// - Score very high: 0 points (spam) -func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { +// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) { if result == nil { - return 0.0 + return 100, "" // No spam scan results, assume good } + // SpamAssassin score typically ranges from -10 to +20 + // Score < 0 is very likely ham (good) + // Score 0-5 is threshold range (configurable, usually 5.0) + // Score > 5 is likely spam + score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5.0 // Default SpamAssassin threshold - } - // Calculate deliverability score - if score <= 0 { - return 2.0 - } else if score < required { - // Linear scaling from 1.5 to 2.0 based on how negative/low the score is - ratio := score / required - return 1.5 + (0.5 * (1.0 - float32(ratio))) - } else if score < required*2 { - // Slightly above threshold - return 1.0 - } else if score < required*3 { - // Moderately high - return 0.5 - } - - // Very high spam score - return 0.0 -} - -// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis -func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check { - var checks []api.Check - - if result == nil { - checks = append(checks, api.Check{ - Category: api.Spam, - Name: "SpamAssassin Analysis", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), - }) - return checks - } - - // Main spam score check - mainCheck := a.generateMainSpamCheck(result) - checks = append(checks, mainCheck) - - // Add checks for significant spam tests (score > 1.0 or < -1.0) - for _, test := range result.Tests { - if detail, ok := result.TestDetails[test]; ok { - if detail.Score > 1.0 || detail.Score < -1.0 { - check := a.generateTestCheck(detail) - checks = append(checks, check) - } - } - } - - return checks -} - -// generateMainSpamCheck creates the main spam score check -func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check { - check := api.Check{ - Category: api.Spam, - Name: "SpamAssassin Score", - } - - score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5.0 - } - - delivScore := a.GetSpamAssassinScore(result) - check.Score = delivScore - - // Determine status and message based on score - if score <= 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") - } else if score < required { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your email passes spam filters") - } else if score < required*1.5 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") - } else if score < required*2 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") + // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) + if score < 0 { + return 100, "A+" // Perfect score for ham + } else if score == 0 { + return 100, "A" // Perfect score for ham + } else if score >= result.RequiredScore { + return 0, "F" // Failed spam test } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") + // Linear scale between 0 and required threshold + percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore)))) + return percentage, ScoreToGrade(percentage - 5) } - - // Add details - if len(result.Tests) > 0 { - details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", ")) - if len(result.Tests) > 5 { - details += fmt.Sprintf(" and %d more", len(result.Tests)-5) - } - check.Details = &details - } - - return check -} - -// generateTestCheck creates a check for a specific spam test -func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check { - check := api.Check{ - Category: api.Spam, - Name: fmt.Sprintf("Spam Test: %s", detail.Name), - } - - if detail.Score > 0 { - // Negative indicator (increases spam score) - if detail.Score > 2.0 { - check.Status = api.CheckStatusFail - check.Severity = api.PtrTo(api.CheckSeverityHigh) - } else { - check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.CheckSeverityMedium) - } - check.Score = 0.0 - check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) - advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) - check.Advice = &advice - } else { - // Positive indicator (decreases spam score) - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) - advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) - check.Advice = &advice - } - - check.Details = &detail.Description - - return check -} - -// min returns the minimum of two integers -func min(a, b int) int { - if a < b { - return a - } - return b } diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index e7491db..d5e67a9 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -27,7 +27,8 @@ import ( "strings" "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseSpamStatus(t *testing.T) { @@ -35,8 +36,8 @@ func TestParseSpamStatus(t *testing.T) { name string header string expectedIsSpam bool - expectedScore float64 - expectedReq float64 + expectedScore float32 + expectedReq float32 expectedTests []string }{ { @@ -77,8 +78,8 @@ func TestParseSpamStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } analyzer.parseSpamStatus(tt.header, result) @@ -91,8 +92,12 @@ func TestParseSpamStatus(t *testing.T) { if result.RequiredScore != tt.expectedReq { t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq) } - if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) { - t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests) + if len(tt.expectedTests) > 0 { + if result.Tests == nil { + t.Errorf("Tests = nil, want %v", tt.expectedTests) + } else if !stringSliceEqual(*result.Tests, tt.expectedTests) { + t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests) + } } }) } @@ -111,27 +116,27 @@ func TestParseSpamReport(t *testing.T) { ` analyzer := NewSpamAssassinAnalyzer() - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } analyzer.parseSpamReport(report, result) - expectedTests := map[string]SpamTestDetail{ + expectedTests := map[string]model.SpamTestDetail{ "BAYES_99": { Name: "BAYES_99", Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", + Description: utils.PtrTo("Bayes spam probability is 99 to 100%"), }, "SPOOFED_SENDER": { Name: "SPOOFED_SENDER", Score: 3.5, - Description: "From address doesn't match envelope sender", + Description: utils.PtrTo("From address doesn't match envelope sender"), }, "ALL_TRUSTED": { Name: "ALL_TRUSTED", Score: -1.0, - Description: "All mail servers are trusted", + Description: utils.PtrTo("All mail servers are trusted"), }, } @@ -144,8 +149,8 @@ func TestParseSpamReport(t *testing.T) { if detail.Score != expected.Score { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score) } - if detail.Description != expected.Description { - t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description) + if *detail.Description != *expected.Description { + t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description) } } } @@ -153,56 +158,63 @@ func TestParseSpamReport(t *testing.T) { func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string - result *SpamAssassinResult - expectedScore float32 - minScore float32 - maxScore float32 + result *model.SpamAssassinResult + expectedScore int + minScore int + maxScore int }{ { name: "Nil result", result: nil, - expectedScore: 0.0, + expectedScore: 100, }, { name: "Excellent score (negative)", - result: &SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Good score (below threshold)", - result: &SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 2.0, RequiredScore: 5.0, }, - minScore: 1.5, - maxScore: 2.0, + expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60 }, { - name: "Borderline (just above threshold)", - result: &SpamAssassinResult{ + name: "Score at threshold", + result: &model.SpamAssassinResult{ + Score: 5.0, + RequiredScore: 5.0, + }, + expectedScore: 0, // >= threshold = 0 + }, + { + name: "Above threshold (spam)", + result: &model.SpamAssassinResult{ Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 1.0, + expectedScore: 0, // >= threshold = 0 }, { name: "High spam score", - result: &SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 0.5, + expectedScore: 0, // >= threshold = 0 }, { name: "Very high spam score", - result: &SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0.0, + expectedScore: 0, // >= threshold = 0 }, } @@ -210,7 +222,7 @@ func TestGetSpamAssassinScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := analyzer.GetSpamAssassinScore(tt.result) + score, _ := analyzer.CalculateSpamAssassinScore(tt.result) if tt.minScore > 0 || tt.maxScore > 0 { if score < tt.minScore || score > tt.maxScore { @@ -230,7 +242,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) { name string headers map[string]string expectedIsSpam bool - expectedScore float64 + expectedScore float32 expectedHasDetails bool }{ { @@ -296,86 +308,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) { } } -func TestGenerateSpamAssassinChecks(t *testing.T) { - tests := []struct { - name string - result *SpamAssassinResult - expectedStatus api.CheckStatus - minChecks int - }{ - { - name: "Nil result", - result: nil, - expectedStatus: api.CheckStatusWarn, - minChecks: 1, - }, - { - name: "Clean email", - result: &SpamAssassinResult{ - IsSpam: false, - Score: -0.5, - RequiredScore: 5.0, - Tests: []string{"ALL_TRUSTED"}, - TestDetails: map[string]SpamTestDetail{ - "ALL_TRUSTED": { - Name: "ALL_TRUSTED", - Score: -1.5, - Description: "All mail servers are trusted", - }, - }, - }, - expectedStatus: api.CheckStatusPass, - minChecks: 2, // Main check + one test detail - }, - { - name: "Spam email", - result: &SpamAssassinResult{ - IsSpam: true, - Score: 15.0, - RequiredScore: 5.0, - Tests: []string{"BAYES_99", "SPOOFED_SENDER"}, - TestDetails: map[string]SpamTestDetail{ - "BAYES_99": { - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - "SPOOFED_SENDER": { - Name: "SPOOFED_SENDER", - Score: 3.5, - Description: "From address doesn't match envelope sender", - }, - }, - }, - expectedStatus: api.CheckStatusFail, - minChecks: 3, // Main check + two significant tests - }, - } - - analyzer := NewSpamAssassinAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateSpamAssassinChecks(tt.result) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Check main check (first one) - if len(checks) > 0 { - mainCheck := checks[0] - if mainCheck.Status != tt.expectedStatus { - t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus) - } - if mainCheck.Category != api.Spam { - t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) - } - } - }) - } -} - func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { analyzer := NewSpamAssassinAnalyzer() email := &EmailMessage{ @@ -389,98 +321,6 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { } } -func TestGenerateMainSpamCheck(t *testing.T) { - analyzer := NewSpamAssassinAnalyzer() - - tests := []struct { - name string - score float64 - required float64 - expectedStatus api.CheckStatus - }{ - {"Excellent", -1.0, 5.0, api.CheckStatusPass}, - {"Good", 2.0, 5.0, api.CheckStatusPass}, - {"Borderline", 6.0, 5.0, api.CheckStatusWarn}, - {"High", 8.0, 5.0, api.CheckStatusWarn}, - {"Very High", 15.0, 5.0, api.CheckStatusFail}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - Score: tt.score, - RequiredScore: tt.required, - } - - check := analyzer.generateMainSpamCheck(result) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Message, "spam score") { - t.Error("Message should contain 'spam score'") - } - }) - } -} - -func TestGenerateTestCheck(t *testing.T) { - analyzer := NewSpamAssassinAnalyzer() - - tests := []struct { - name string - detail SpamTestDetail - expectedStatus api.CheckStatus - }{ - { - name: "High penalty test", - detail: SpamTestDetail{ - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - expectedStatus: api.CheckStatusFail, - }, - { - name: "Medium penalty test", - detail: SpamTestDetail{ - Name: "HTML_MESSAGE", - Score: 1.5, - Description: "Contains HTML", - }, - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Positive test", - detail: SpamTestDetail{ - Name: "ALL_TRUSTED", - Score: -2.0, - Description: "All mail servers are trusted", - }, - expectedStatus: api.CheckStatusPass, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateTestCheck(tt.detail) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Name, tt.detail.Name) { - t.Errorf("Check name should contain test name %s", tt.detail.Name) - } - }) - } -} - const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID, DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED, @@ -542,24 +382,26 @@ func TestAnalyzeRealEmailExample(t *testing.T) { } // Validate score (should be -0.1) - expectedScore := -0.1 + var expectedScore float32 = -0.1 if result.Score != expectedScore { t.Errorf("Score = %v, want %v", result.Score, expectedScore) } // Validate required score (should be 5.0) - expectedRequired := 5.0 + var expectedRequired float32 = 5.0 if result.RequiredScore != expectedRequired { t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired) } // Validate version - if !strings.Contains(result.Version, "SpamAssassin") { - t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version) + if result.Version == nil { + t.Errorf("Version should contain 'SpamAssassin', got: nil") + } else if !strings.Contains(*result.Version, "SpamAssassin") { + t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version) } // Validate that tests were extracted - if len(result.Tests) == 0 { + if len(*result.Tests) == 0 { t.Error("Expected tests to be extracted, got none") } @@ -572,7 +414,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) { "SPF_HELO_NONE": true, } - for _, testName := range result.Tests { + for _, testName := range *result.Tests { if expectedTests[testName] { t.Logf("Found expected test: %s", testName) } @@ -586,11 +428,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Log what we actually got for debugging t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails)) for name, detail := range result.TestDetails { - t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description) + t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description) } // Define expected test details with their scores - expectedTestDetails := map[string]float64{ + expectedTestDetails := map[string]float32{ "SPF_PASS": -0.0, "SPF_HELO_NONE": 0.0, "DKIM_VALID": -0.1, @@ -611,43 +453,15 @@ func TestAnalyzeRealEmailExample(t *testing.T) { if detail.Score != expectedScore { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore) } - if detail.Description == "" { + if detail.Description == nil || *detail.Description == "" { t.Errorf("Test %s should have a description", testName) } } // Test GetSpamAssassinScore - score := analyzer.GetSpamAssassinScore(result) - if score != 2.0 { - t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) - } - - // Test GenerateSpamAssassinChecks - checks := analyzer.GenerateSpamAssassinChecks(result) - if len(checks) < 1 { - t.Fatal("Expected at least 1 check, got none") - } - - // Main check should be PASS with excellent score - mainCheck := checks[0] - if mainCheck.Status != api.CheckStatusPass { - t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass) - } - if mainCheck.Category != api.Spam { - t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) - } - if !strings.Contains(mainCheck.Message, "spam score") { - t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message) - } - if mainCheck.Score != 2.0 { - t.Errorf("Main check score = %v, want 2.0", mainCheck.Score) - } - - // Log all checks for debugging - t.Logf("Generated %d checks:", len(checks)) - for i, check := range checks { - t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)", - i+1, check.Name, check.Message, check.Score, check.Status) + score, _ := analyzer.CalculateSpamAssassinScore(result) + if score != 100 { + t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) } } diff --git a/web/package-lock.json b/web/package-lock.json index 3fbf1f1..27e6fc1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,31 +13,65 @@ "bootstrap-icons": "^1.13.1" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^10.0.0", + "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", - "@sveltejs/vite-plugin-svelte": "^6.2.0", - "@types/node": "^22", - "eslint": "^9.36.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^24.0.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", - "typescript": "^5.9.2", + "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", - "vite": "^7.1.10", + "vite": "^8.0.0", "vitest": "^3.2.4" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -52,9 +86,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -69,9 +103,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -86,9 +120,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -103,9 +137,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -120,9 +154,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -137,9 +171,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -154,9 +188,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -171,9 +205,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -188,9 +222,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -205,9 +239,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -222,9 +256,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -239,9 +273,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -256,9 +290,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -273,9 +307,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -290,9 +324,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -307,9 +341,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -324,9 +358,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -341,9 +375,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -358,9 +392,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -375,9 +409,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -392,9 +426,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -409,9 +443,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -426,9 +460,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -443,9 +477,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -460,9 +494,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -477,9 +511,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -509,9 +543,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -519,19 +553,19 @@ } }, "node_modules/@eslint/compat": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", - "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz", + "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^8.40 || 9" + "eslint": "^8.40 || 9 || 10" }, "peerDependenciesMeta": { "eslint": { @@ -540,128 +574,99 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hey-api/codegen-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz", - "integrity": "sha512-c7VjBy/8ed0EVLNgaeS9Xxams1Tuv/WK/b4xXH3Qr4wjzYeJUtxOcoP8YdwNLavqKP8pGiuctjX2Z1Pwc4jMgQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz", + "integrity": "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=22.10.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -671,9 +676,9 @@ } }, "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.0.tgz", - "integrity": "sha512-BMnIuhVgNmSudadw1GcTsP18Yk5l8FrYrg/OSYNxz0D2E0vf4D5e4j5nUbuY8MU6p1vp7ev0xrfP6A/NWazkzQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz", + "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==", "dev": true, "license": "MIT", "dependencies": { @@ -690,27 +695,27 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.85.2.tgz", - "integrity": "sha512-pNu+DOtjeXiGhMqSQ/mYadh6BuKR/QiucVunyA2P7w2uyxkfCJ9sHS20Y72KHXzB3nshKJ9r7JMirysoa50SJg==", + "version": "0.86.10", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.10.tgz", + "integrity": "sha512-Ns0dTJp/RUrOMPiJsO4/1E2Sa3VZ1iw2KCdG6PDbd9vLwOXEYW2UmiWMDPOTInLCYB+f8FLMF9T25jtfQe7AZg==", "dev": true, "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "^0.2.0", - "@hey-api/json-schema-ref-parser": "1.2.0", + "@hey-api/codegen-core": "^0.3.2", + "@hey-api/json-schema-ref-parser": "1.2.1", "ansi-colors": "4.1.3", - "c12": "3.3.0", + "c12": "3.3.1", "color-support": "1.1.3", - "commander": "13.0.0", + "commander": "14.0.1", "handlebars": "4.7.8", - "open": "10.1.2", + "open": "10.2.0", "semver": "7.7.2" }, "bin": { - "openapi-ts": "bin/index.cjs" + "openapi-ts": "bin/run.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -720,29 +725,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -828,42 +847,33 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@polka/url": { @@ -884,10 +894,292 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -899,9 +1191,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -913,9 +1205,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -927,9 +1219,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -941,9 +1233,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -955,9 +1247,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -969,13 +1261,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -983,13 +1278,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -997,13 +1295,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1011,13 +1312,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1025,13 +1329,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1039,13 +1363,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1053,13 +1397,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1067,13 +1414,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1081,13 +1431,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1095,13 +1448,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1465,26 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -1119,13 +1492,13 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -1137,9 +1510,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -1151,9 +1524,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -1165,9 +1538,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -1179,9 +1552,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -1193,16 +1566,16 @@ ] }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1220,25 +1593,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.47.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.2.tgz", - "integrity": "sha512-mbUomaJTiADTrq6GT4ZvQ7v1rs0S+wXGMzrjFwjARAKMEF8FpOUmz2uEJ4M9WMJMQOXCMHpKFzJfdjo9O7M22A==", + "version": "2.60.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz", + "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.3.2", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", + "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "bin": { @@ -1249,64 +1620,60 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true + }, + "typescript": { + "optional": true } } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", - "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", - "debug": "^4.4.1", "deepmerge": "^4.3.1", - "magic-string": "^0.30.17", - "vitefu": "^1.1.1" + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" }, "engines": { "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", - "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "debug": "^4.4.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "tslib": "^2.4.0" } }, "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/cookie": { @@ -1323,10 +1690,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1338,32 +1712,37 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1373,9 +1752,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1389,18 +1768,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1410,20 +1788,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1433,18 +1811,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1455,9 +1833,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -1468,21 +1846,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1492,14 +1870,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -1511,22 +1889,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1536,46 +1913,33 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1585,19 +1949,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1624,33 +1988,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -1723,12 +2060,11 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1747,9 +2083,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1773,22 +2109,6 @@ "node": ">=6" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1797,9 +2117,9 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1827,11 +2147,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bootstrap": { "version": "5.3.8", @@ -1869,27 +2192,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, "node_modules/bundle-name": { @@ -1909,19 +2221,19 @@ } }, "node_modules/c12": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz", - "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.1.tgz", + "integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", - "dotenv": "^17.2.2", + "dotenv": "^17.2.3", "exsolve": "^1.0.7", "giget": "^2.0.0", - "jiti": "^2.5.1", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", @@ -1947,16 +2259,6 @@ "node": ">=8" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1974,27 +2276,10 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2037,26 +2322,6 @@ "node": ">=6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -2068,26 +2333,19 @@ } }, "node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "dev": true, "license": "MIT" }, @@ -2185,9 +2443,9 @@ } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -2202,9 +2460,9 @@ } }, "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -2228,9 +2486,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -2241,17 +2499,27 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", - "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2269,9 +2537,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2282,32 +2550,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escape-string-regexp": { @@ -2324,34 +2592,30 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2361,8 +2625,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2370,7 +2633,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -2401,9 +2664,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.5.tgz", - "integrity": "sha512-4KRG84eAHQfYd9OjZ1K7sCHy0nox+9KwT+s5WCCku3jTim5RV4tVENob274nCwIaApXsYPKAUAZFBxKZ3Wyfjw==", + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.1.tgz", + "integrity": "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2425,7 +2688,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0", + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2434,31 +2697,46 @@ } } }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2472,27 +2750,27 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2503,13 +2781,21 @@ } }, "node_modules/esrap": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", - "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/esrecurse": { @@ -2556,9 +2842,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2566,9 +2852,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2579,36 +2865,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2623,16 +2879,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2664,19 +2910,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2709,9 +2942,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2762,9 +2995,9 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -2774,13 +3007,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -2803,16 +3029,6 @@ "uglify-js": "^3.1.4" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2823,23 +3039,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2908,16 +3107,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2929,9 +3118,9 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -2952,9 +3141,9 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -2969,9 +3158,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3043,6 +3232,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -3077,16 +3539,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -3098,63 +3553,29 @@ "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3195,9 +3616,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3235,25 +3656,41 @@ "license": "MIT" }, "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", "dev": true, "license": "MIT", "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", + "citty": "^0.2.2", "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" + "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" }, "engines": { - "node": "^14.16.0 || >=16.10.0" + "node": ">=18" } }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -3262,16 +3699,16 @@ "license": "MIT" }, "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "wsl-utils": "^0.1.0" }, "engines": { "node": ">=18" @@ -3330,19 +3767,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3381,9 +3805,9 @@ } }, "node_modules/perfect-debounce": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", - "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "dev": true, "license": "MIT" }, @@ -3395,12 +3819,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3409,21 +3832,21 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -3440,7 +3863,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3481,9 +3903,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -3545,9 +3967,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -3569,12 +3991,11 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3586,9 +4007,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", + "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3606,27 +4027,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -3652,31 +4052,44 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -3690,31 +4103,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -3728,30 +4151,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3779,9 +4178,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "dev": true, "license": "MIT" }, @@ -3864,19 +4263,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -3890,37 +4276,25 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/svelte": { - "version": "5.41.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.41.0.tgz", - "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==", + "version": "5.55.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", + "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "^5.3.1", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3931,9 +4305,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", - "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", "dev": true, "license": "MIT", "dependencies": { @@ -3955,9 +4329,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", - "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.1.tgz", + "integrity": "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3966,11 +4340,12 @@ "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.18.3" + "pnpm": "10.33.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -3984,6 +4359,54 @@ } } }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3992,21 +4415,24 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4045,19 +4471,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4069,9 +4482,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4081,6 +4494,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4095,12 +4516,11 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4110,16 +4530,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz", - "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.1", - "@typescript-eslint/parser": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4129,8 +4549,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uglify-js": { @@ -4148,9 +4568,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -4172,14 +4592,114 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", - "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4247,33 +4767,10 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "dev": true, "license": "MIT", "workspaces": [ @@ -4282,7 +4779,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "vite": { @@ -4363,6 +4860,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -4370,6 +4894,81 @@ "dev": true, "license": "MIT" }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4420,6 +5019,38 @@ "dev": true, "license": "MIT" }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index d0a2578..90b545e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,24 +16,24 @@ "generate:api": "openapi-ts" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^10.0.0", + "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", - "@sveltejs/vite-plugin-svelte": "^6.2.0", - "@types/node": "^22", - "eslint": "^9.36.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^24.0.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", - "typescript": "^5.9.2", + "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", - "vite": "^7.1.10", + "vite": "^8.0.0", "vitest": "^3.2.4" }, "dependencies": { diff --git a/web/routes.go b/web/routes.go index 754c1b2..056115d 100644 --- a/web/routes.go +++ b/web/routes.go @@ -23,9 +23,10 @@ package web import ( "encoding/json" + "flag" + "fmt" "io" "io/fs" - "io/ioutil" "log" "net/http" "net/url" @@ -41,12 +42,38 @@ import ( var ( indexTpl *template.Template + CustomBodyHTML = "" CustomHeadHTML = "" ) +func init() { + flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before ") + flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before ") +} + func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig := map[string]interface{}{} + if cfg.ReportRetention > 0 { + appConfig["report_retention"] = cfg.ReportRetention + } + + if cfg.SurveyURL.Host != "" { + appConfig["survey_url"] = cfg.SurveyURL.String() + } + + if len(cfg.Analysis.RBLs) > 0 { + appConfig["rbls"] = cfg.Analysis.RBLs + } + + if cfg.CustomLogoURL != "" { + appConfig["custom_logo_url"] = cfg.CustomLogoURL + } + + if !cfg.DisableTestList { + appConfig["test_list_enabled"] = true + } + if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { log.Println("Unable to generate JSON config to inject in web application") } else { @@ -66,6 +93,13 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/", serveOrReverse("/", cfg)) + router.GET("/blacklist/", serveOrReverse("/", cfg)) + router.GET("/blacklist/:ip", serveOrReverse("/", cfg)) + router.GET("/domain/", serveOrReverse("/", cfg)) + router.GET("/domain/:domain", serveOrReverse("/", cfg)) + router.GET("/test/", serveOrReverse("/", cfg)) + router.GET("/test/:testid", serveOrReverse("/", cfg)) + router.GET("/history/", serveOrReverse("/", cfg)) router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/img/*path", serveOrReverse("", cfg)) @@ -85,7 +119,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { if u, err := url.Parse(cfg.DevProxy); err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) } else { - if forced_url != "" { + if forced_url != "" && forced_url != "/" { u.Path = path.Join(u.Path, forced_url) } else { u.Path = path.Join(u.Path, c.Request.URL.Path) @@ -114,14 +148,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { } } - v, _ := ioutil.ReadAll(resp.Body) + v, _ := io.ReadAll(resp.Body) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } @@ -139,16 +175,18 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { if indexTpl == nil { // Create template from file f, _ := Assets.Open("index.html") - v, _ := ioutil.ReadAll(f) + v, _ := io.ReadAll(f) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) } // Serve template if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } diff --git a/web/src/app.css b/web/src/app.css index ddae5b6..dca80a5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,6 +1,9 @@ :root { --bs-primary: #1cb487; --bs-primary-rgb: 28, 180, 135; + --bs-link-color-rgb: 28, 180, 135; + --bs-link-hover-color-rgb: 17, 112, 84; + --bs-tertiary-bg: #e7e8e8; } body { @@ -8,6 +11,10 @@ body { -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } +.bg-tertiary { + background-color: var(--bs-tertiary-bg); +} + /* Animations */ @keyframes fadeIn { from { @@ -74,14 +81,21 @@ body { /* Custom card styling */ .card { - border: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease; } -.card:hover { +.card:not(.fade-in .card) { + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.fade-in .card:not(.card .card) { + border: none; +} + +.card:hover:not(.fade-in .card) { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } diff --git a/web/src/app.html b/web/src/app.html index 1966776..9e3bf88 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -3,9 +3,38 @@ + + + + + + + + + + + + + %sveltekit.head% +
%sveltekit.body%
diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..fb235b0 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + h + + + + + diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte new file mode 100644 index 0000000..46a4d2d --- /dev/null +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -0,0 +1,567 @@ + + +
+
+

+ + + Authentication + + + {#if authenticationScore !== undefined} + + {authenticationScore}% + + {/if} + {#if authenticationGrade !== undefined} + + {/if} + +

+
+ {#if allRequiredMissing} +
+
+ + No authentication results found. +

+ This usually means either: +

+
    +
  • + The receiving mail server is not configured to verify email authentication + (no Authentication-Results header was found in the message). +
  • +
  • + The Authentication-Results header exists but the receiver + hostname does not match the configured + --receiver-hostname value. +
  • +
+
+
+ {/if} +
+ + {#if authentication.iprev} +
+
+ +
+ IP Reverse DNS + + {authentication.iprev.result} + + {#if authentication.iprev.ip} +
+ IP Address: + {authentication.iprev.ip} +
+ {/if} + {#if authentication.iprev.hostname} +
+ Hostname: + {authentication.iprev.hostname} +
+ {/if} + {#if authentication.iprev.details} +
{authentication.iprev.details}
+ {/if} +
+
+
+ {/if} + + +
+
+ {#if authentication.spf} + +
+ SPF + + {authentication.spf.result} + + {#if authentication.spf.domain} +
+ Domain: + {authentication.spf.domain} +
+ {/if} + {#if authentication.spf.details} +
{authentication.spf.details}
+ {/if} +
+ {:else} + +
+ SPF + + {getAuthResultText("missing")} + +
+ SPF record is required for proper email authentication +
+
+ {/if} +
+
+ + +
+ {#if authentication.dkim && authentication.dkim.length > 0} + {#each authentication.dkim as dkim, i} +
0}> + +
+ DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""} + + {dkim.result} + + {#if dkim.domain} +
+ Domain: + {dkim.domain} +
+ {/if} + {#if dkim.selector} +
+ Selector: + {dkim.selector} +
+ {/if} + {#if dkim.details} +
{dkim.details}
+ {/if} +
+
+ {/each} + {:else} +
+ +
+ DKIM + + {getAuthResultText("missing")} + +
+ DKIM signature is required for proper email authentication +
+
+
+ {/if} +
+ + + {#if authentication.x_google_dkim} +
+
+ +
+ X-Google-DKIM + + + {authentication.x_google_dkim.result} + + {#if authentication.x_google_dkim.domain} +
+ Domain: + {authentication.x_google_dkim.domain} +
+ {/if} + {#if authentication.x_google_dkim.selector} +
+ Selector: + {authentication.x_google_dkim.selector} +
+ {/if} + {#if authentication.x_google_dkim.details} +
{authentication.x_google_dkim
+                                    .details}
+ {/if} +
+
+
+ {/if} + + + {#if authentication.x_aligned_from} +
+
+ +
+ X-Aligned-From + + + {authentication.x_aligned_from.result} + + {#if authentication.x_aligned_from.domain} +
+ Domain: + {authentication.x_aligned_from.domain} +
+ {/if} + {#if authentication.x_aligned_from.details} +
{authentication.x_aligned_from
+                                    .details}
+ {/if} +
+
+
+ {/if} + + +
+
+ {#if authentication.dmarc} + +
+ DMARC + + {authentication.dmarc.result} + + {#if authentication.dmarc.domain} +
+ Domain: + {authentication.dmarc.domain} +
+ {/if} + {#snippet DMARCPolicy(policy: string)} +
+ Policy: + + {policy} + +
+ {/snippet} + {#if authentication.dmarc.result != "none"} + {#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} + {@const policy = authentication.dmarc.details.replace( + /^.*policy.published-domain-policy=([^\s]+).*$/, + "$1", + )} + {@render DMARCPolicy(policy)} + {:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy} + {@render DMARCPolicy(dnsResults.dmarc_record.policy)} + {/if} + {/if} + {#if authentication.dmarc.details} +
{authentication.dmarc.details}
+ {/if} +
+ {:else} + +
+ DMARC + + {getAuthResultText("missing")} + +
+ DMARC policy is required for proper email authentication +
+
+ {/if} +
+
+ + +
+
+ {#if authentication.bimi && authentication.bimi.result != "none"} + +
+ BIMI + + {authentication.bimi.result} + + {#if authentication.bimi.details} +
{authentication.bimi.details}
+ {/if} +
+ {:else if authentication.bimi && authentication.bimi.result == "none"} + +
+ BIMI + NONE +
+ Brand Indicators for Message Identification +
+ {#if authentication.bimi.details} +
{authentication.bimi.details}
+ {/if} +
+ {:else} + +
+ BIMI + Optional +
+ Brand Indicators for Message Identification (optional enhancement) +
+
+ {/if} +
+
+ + + {#if authentication.arc} +
+
+ +
+ ARC + + {authentication.arc.result} + + {#if authentication.arc.chain_length} +
+ Chain length: {authentication.arc.chain_length} +
+ {/if} + {#if authentication.arc.details} +
{authentication.arc.details}
+ {/if} +
+
+
+ {/if} +
+
diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte new file mode 100644 index 0000000..889e24f --- /dev/null +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -0,0 +1,77 @@ + + +{#if bimiRecord} +
+
+
+ + Brand Indicators for Message Identification +
+ BIMI +
+
+

+ BIMI allows your brand logo to be displayed next to your emails in supported mail + clients. Requires strong DMARC enforcement (quarantine or reject policy) and + optionally a Verified Mark Certificate (VMC). +

+ +
+ +
+ Selector: {bimiRecord.selector} + Domain: {bimiRecord.domain} +
+
+ Status: + {#if bimiRecord.valid} + Valid + {:else} + Invalid + {/if} +
+ {#if bimiRecord.logo_url} +
+ Logo URL: + {bimiRecord.logo_url} +
+ {/if} + {#if bimiRecord.vmc_url} +
+ VMC URL: + {bimiRecord.vmc_url} +
+ {/if} + {#if bimiRecord.record} +
+ Record:
+ {bimiRecord.record} +
+ {/if} + {#if bimiRecord.error} +
+ Error: + {bimiRecord.error} +
+ {/if} +
+
+{/if} diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte new file mode 100644 index 0000000..bb80acb --- /dev/null +++ b/web/src/lib/components/BlacklistCard.svelte @@ -0,0 +1,71 @@ + + +
+
+

+ + + Blacklist Checks + + + {#if blacklistScore !== undefined} + + {blacklistScore}% + + {/if} + {#if blacklistGrade !== undefined} + + {/if} + +

+
+
+
+ {#each Object.entries(blacklists) as [ip, checks]} +
+
+ + {ip} +
+ + + {#each checks as check} + + + + + {/each} + +
+ + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Clean"} + + {check.rbl}
+
+ {/each} +
+
+
diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte deleted file mode 100644 index bc5741c..0000000 --- a/web/src/lib/components/CheckCard.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
-
-
-
- -
-
-
-
-
{check.name}
- {check.category} -
- {check.score.toFixed(1)} pts -
- -

{check.message}

- - {#if check.advice} - - {/if} - - {#if check.details} -
- Technical Details -
{check.details}
-
- {/if} -
-
-
-
- - diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte new file mode 100644 index 0000000..51c4e5b --- /dev/null +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -0,0 +1,199 @@ + + +
+
+

+ + + Content Analysis + + + {#if contentScore !== undefined} + + {contentScore}% + + {/if} + {#if contentGrade !== undefined} + + {/if} + +

+
+
+
+
+
+ + HTML Part +
+
+ + Plaintext Part +
+ {#if typeof contentAnalysis.has_unsubscribe_link === "boolean"} +
+ + Unsubscribe Link +
+ {/if} +
+
+ {#if contentAnalysis.text_to_image_ratio !== undefined} +
+ Text to Image Ratio: + {contentAnalysis.text_to_image_ratio.toFixed(2)} +
+ {/if} + {#if contentAnalysis.unsubscribe_methods && contentAnalysis.unsubscribe_methods.length > 0} +
+ Unsubscribe Methods: +
+ {#each contentAnalysis.unsubscribe_methods as method} + {method} + {/each} +
+
+ {/if} +
+
+ + {#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0} +
+
Content Issues
+ {#each contentAnalysis.html_issues as issue} +
+
+
+ {issue.type} +
{issue.message}
+ {#if issue.location} +
{issue.location}
+ {/if} + {#if issue.advice} +
+ + {issue.advice} +
+ {/if} +
+ {issue.severity} +
+
+ {/each} +
+ {/if} + + {#if contentAnalysis.links && contentAnalysis.links.length > 0} +
+
Links ({contentAnalysis.links.length})
+
+ + + + + + + + + + {#each contentAnalysis.links as link} + + + + + + {/each} + +
URLStatusHTTP Code
+ {link.url} + {#if link.is_shortened} + Shortened + {/if} + + + {link.status} + + {link.http_code || "-"}
+
+
+ {/if} + + {#if contentAnalysis.images && contentAnalysis.images.length > 0} +
+
Images ({contentAnalysis.images.length})
+
+ + + + + + + + + + {#each contentAnalysis.images as image} + + + + + + {/each} + +
SourceAlt TextTracking
{image.src || "-"} + {#if image.has_alt} + + {image.alt_text || "Present"} + {:else} + + Missing + {/if} + + {#if image.is_tracking_pixel} + Tracking Pixel + {:else} + - + {/if} +
+
+
+ {/if} +
+
diff --git a/web/src/lib/components/DkimRecordsDisplay.svelte b/web/src/lib/components/DkimRecordsDisplay.svelte new file mode 100644 index 0000000..11a1b00 --- /dev/null +++ b/web/src/lib/components/DkimRecordsDisplay.svelte @@ -0,0 +1,74 @@ + + +
+
+
+ + DomainKeys Identified Mail +
+ DKIM +
+
+

+ DKIM cryptographically signs your emails, proving they haven't been tampered with in + transit. Receiving servers verify this signature against your DNS records. +

+
+
+ {#if dkimRecords && dkimRecords.length > 0} + {#each dkimRecords as dkim} +
+
+ Selector: {dkim.selector} + Domain: {dkim.domain} +
+
+ Status: + {#if dkim.valid} + Valid + {:else} + Invalid + {/if} +
+ {#if dkim.record} +
+ Record:
+ {dkim.record} +
+ {/if} + {#if dkim.error} +
+ Error: + {dkim.error} +
+ {/if} +
+ {/each} + {:else} +
+ + No DKIM signatures found in this email. DKIM provides cryptographic authentication and + helps avoid spoofing, thus improving deliverability. +
+ {/if} +
+
diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte new file mode 100644 index 0000000..e2b83f0 --- /dev/null +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -0,0 +1,425 @@ + + +{#if dmarcRecord} +
+
+
+ + Domain-based Message Authentication +
+ DMARC +
+
+

+ DMARC enforces domain alignment requirements (regardless of the policy). It builds + on SPF and DKIM by telling receiving servers what to do with emails that fail + authentication checks. It also enables reporting so you can monitor your email + security. +

+ +
+ + +
+ Status: + {#if dmarcRecord.valid} + Valid + {:else} + Invalid + {/if} +
+ + + {#if isFallback} +
+ Record found at: + {dmarcRecord.domain} +
+ + No DMARC record exists for {fromDomain}. The record above was + inherited from + {#if isPsdFallback} + the Public Suffix Domain {dmarcRecord.domain} via the DMARCbis + DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment). + {:else} + the organizational domain {dmarcRecord.domain} via the + DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain + fallback). + {/if} +
+
+ {/if} + + + {#if dmarcRecord.policy} +
+ Policy: + + {dmarcRecord.policy} + + {#if dmarcRecord.policy === "reject"} +
+ + Maximum protection — emails failing DMARC checks are rejected. + This provides the strongest defense against spoofing and phishing. +
+ {:else if dmarcRecord.policy === "quarantine"} +
+ + Good protection — emails failing DMARC checks are + quarantined (sent to spam). This is a safe middle ground.
+ + Once you've validated your configuration and ensured all legitimate mail + passes, consider upgrading to p=reject for maximum protection. +
+ {:else if dmarcRecord.policy === "none"} +
+ + Monitoring only — emails failing DMARC are delivered + normally. This is only recommended during initial setup.
+ + After monitoring reports, upgrade to p=quarantine or + p=reject to actively protect your domain. +
+ {:else} +
+ + Unknown policy — the policy value is not recognized. Valid + options are: none, quarantine, or reject. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.test_mode} +
+ Test Mode: + t=y (active) +
+ + Test mode active — DMARCbis-compliant receivers will + downgrade the effective policy one level: + {#if dmarcRecord.policy === "reject"} + p=reject is applied as p=quarantine. + {:else if dmarcRecord.policy === "quarantine"} + p=quarantine is applied as p=none (no action taken). + {:else} + p=none is unaffected by test mode. + {/if} + Aggregate reports are still generated normally. + This tag replaces the deprecated pct= for gradual rollout. +
+
+ {/if} + + + {#if dmarcRecord.psd === "y"} +
+ Public Suffix Domain: + psd=y +
+ + PSD declared — this domain is declared as a Public Suffix + Domain. DMARCbis-compliant receivers will apply this policy to subdomains + that have no DMARC record of their own when using the DNS Tree Walk algorithm. +
+
+ {:else if dmarcRecord.psd === "n"} +
+ Organizational Domain Boundary: + psd=n +
+ + Org Domain declaredpsd=n explicitly declares + this as an Organizational Domain boundary. Subdomains with separate DNS + delegation will use their own independent DMARCbis Tree Walk. +
+
+ {/if} + + + {#if dmarcRecord.subdomain_policy} + {@const mainStrength = policyStrength(dmarcRecord.policy)} + {@const subStrength = policyStrength(dmarcRecord.subdomain_policy)} +
+ Subdomain Policy: + + {dmarcRecord.subdomain_policy} + + {#if subStrength >= mainStrength} +
+ + Good configuration — subdomain policy is equal to or stricter + than main policy. +
+ {:else} +
+ + Weaker subdomain protection — consider setting + sp={dmarcRecord.policy} to match your main policy for consistent + protection. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Subdomain Policy: + Inherits main policy +
+ + Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection. +
+
+ {/if} + + + {#if dmarcRecord.nonexistent_subdomain_policy} + {@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)} + {@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)} +
+ Non-Existent Subdomain Policy: + + {dmarcRecord.nonexistent_subdomain_policy} + + {#if npStrength >= effectiveSubStrength} +
+ + Good configuration — non-existent subdomain policy is equal to or stricter + than the effective subdomain policy. +
+ {:else} +
+ + Weaker protection for non-existent subdomains — consider setting + np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy} to match your subdomain policy. +
+ {/if} +
+ + The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis), + a draft RFC updating RFC 7489. Support may vary across mail receivers. +
+
+ {/if} + + + {#if dmarcRecord.percentage !== undefined} +
+ Enforcement Percentage: + + {dmarcRecord.percentage}% + +
+ + Deprecated tag — the pct= tag is removed in + DMARCbis. Many receivers already ignore it. For gradual rollout, replace it + with t=y (test mode); for full enforcement, simply remove + pct= from your record. + {#if dmarcRecord.percentage === 0} +
pct=0 is an anti-pattern — it was widely misused + as a signal to bypass DMARC entirely, which is one reason the tag was + removed. Use t=y instead. + {/if} +
+ {#if dmarcRecord.percentage === 100} +
+ + Full enforcement — all messages are subject to DMARC policy. +
+ {:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50} +
+ + Partial enforcement — only {dmarcRecord.percentage}% of + messages are subject to DMARC policy. Receivers ignoring pct= will apply + the full policy regardless. +
+ {:else if dmarcRecord.percentage > 0} +
+ + Low enforcement — only {dmarcRecord.percentage}% of + messages are protected. Receivers ignoring pct= will apply full policy. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Enforcement Percentage: + 100% (default) +
+ + Full enforcement — all messages are subject to DMARC policy + by default. +
+
+ {/if} + + + {#if dmarcRecord.spf_alignment} +
+ SPF Alignment: + + {dmarcRecord.spf_alignment} + + {#if dmarcRecord.spf_alignment === "relaxed"} +
+ + Recommended for most senders — ensures legitimate + subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are + accepted. Ensure all legitimate mail comes from the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.dkim_alignment} +
+ DKIM Alignment: + + {dmarcRecord.dkim_alignment} + + {#if dmarcRecord.dkim_alignment === "relaxed"} +
+ + Recommended for most senders — ensures legitimate + subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are + accepted. Ensure all DKIM signatures use the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.record} +
+ Record:
+ {dmarcRecord.record} +
+ {/if} + + + {#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri} +
+ + Deprecated tags detected — your record contains + {#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri} + rf= and ri= tags that are + {:else if dmarcRecord.deprecated_rf} + the rf= tag that is + {:else} + the ri= tag that is + {/if} + removed in DMARCbis. Modern receivers will ignore + {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}. + {#if dmarcRecord.deprecated_ri} + Aggregate reporting interval is now fixed at ≥ 24 hours regardless of + ri=. + {/if} + You can safely remove + {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"} + from your DMARC record. +
+ {/if} + + + {#if dmarcRecord.error} +
+ Error: + {dmarcRecord.error} +
+ {/if} +
+
+{/if} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte new file mode 100644 index 0000000..6dabe0b --- /dev/null +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -0,0 +1,177 @@ + + +
+
+

+ + + DNS Records + + + {#if dnsScore !== undefined} + + {dnsScore}% + + {/if} + {#if dnsGrade !== undefined} + + {/if} + +

+
+
+ {#if !dnsResults} +

No DNS results available

+ {:else} + {#if dnsResults.errors && dnsResults.errors.length > 0} +
+ Errors: +
    + {#each dnsResults.errors as error} +
  • {error}
  • + {/each} +
+
+ {/if} + + {#if !domainOnly} + + {#if receivedChain && receivedChain.length > 0} +
+

+ Received from: {receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0] + .ip}]) +

+
+ {/if} + + + + + + + +
+ + +
+
+

+ Return-Path Domain: + {dnsResults.rp_domain || dnsResults.from_domain} +

+ {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)} + + Differs from From domain + + + + See domain alignment + + {:else} + Same as From domain + {/if} +
+
+ + + {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} + + {/if} + {/if} + + + + + {#if !domainOnly} +
+ + +
+

+ From Domain: {dnsResults.from_domain} +

+ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} + + Differs from Return-Path + domain + + {/if} +
+ {/if} + + + {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} + + {/if} + + {#if !domainOnly} + + + {/if} + + + + + + + {/if} +
+
diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte index aa79f9e..5b5f051 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -1,10 +1,13 @@ -
-
- {email} +
+
+
{#if copied} - + Copied to clipboard! {/if} diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte new file mode 100644 index 0000000..a4fda45 --- /dev/null +++ b/web/src/lib/components/EmailPathCard.svelte @@ -0,0 +1,67 @@ + + +{#if receivedChain && receivedChain.length > 0} +
+
+

+ + Email Path +

+
+
+ {#each receivedChain as hop, i} +
+
+
+ {receivedChain.length - i} + {hop.reverse || "-"} + {#if hop.ip}({hop.ip}){/if} → {hop.by || + "Unknown"} +
+ + {hop.timestamp + ? new Intl.DateTimeFormat("default", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(hop.timestamp)) + : "-"} + +
+ {#if hop.with || hop.id || hop.from} +

+ {#if hop.with} + + Protocol: + {hop.with} + + {/if} + {#if hop.id} + + ID: {hop.id} + + {/if} + {#if hop.from} + + Helo: {hop.from} + + {/if} +

+ {/if} +
+ {/each} +
+
+{/if} diff --git a/web/src/lib/components/ErrorDisplay.svelte b/web/src/lib/components/ErrorDisplay.svelte new file mode 100644 index 0000000..96cfae2 --- /dev/null +++ b/web/src/lib/components/ErrorDisplay.svelte @@ -0,0 +1,158 @@ + + +
+
+ +
+ +
+ + +

{status}

+ + +

{getErrorTitle(status)}

+ + +

{getErrorDescription(status)}

+ + + {#if message && message !== defaultDescription} + + {/if} + + + {#if showActions} +
+ + + Go Home + + +
+ {/if} + + + {#if status === 404 && showActions} +
+

Looking for something specific?

+ +
+ {/if} +
+
+ + diff --git a/web/src/lib/components/GradeDisplay.svelte b/web/src/lib/components/GradeDisplay.svelte new file mode 100644 index 0000000..f9d1f78 --- /dev/null +++ b/web/src/lib/components/GradeDisplay.svelte @@ -0,0 +1,59 @@ + + + + {#if grade} + {grade} + {:else} + {score}% + {/if} + + + diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte new file mode 100644 index 0000000..73c39e8 --- /dev/null +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -0,0 +1,425 @@ + + +
+
+

+ + + Header Analysis + + + {#if headerScore !== undefined} + + {headerScore}% + + {/if} + {#if headerGrade !== undefined} + + {/if} + +

+
+
+ {#if headerAnalysis.issues && headerAnalysis.issues.length > 0} +
+
Issues
+ {#each headerAnalysis.issues as issue} +
+
+
+ {issue.header} +
{issue.message}
+ {#if issue.advice} +
+ + {issue.advice} +
+ {/if} +
+ {issue.severity} +
+
+ {/each} +
+ {/if} + + {#if headerAnalysis.domain_alignment} + {@const spfStrictAligned = + headerAnalysis.domain_alignment.from_domain === + headerAnalysis.domain_alignment.return_path_domain} + {@const spfRelaxedAligned = + headerAnalysis.domain_alignment.from_org_domain === + headerAnalysis.domain_alignment.return_path_org_domain} +
+
+
+ + Domain Alignment +
+
+
+

+ Domain alignment ensures that the visible "From" domain matches the domain + used for authentication (Return-Path or DKIM signature). Proper alignment is + crucial for DMARC compliance, regardless of the policy. It helps prevent + email spoofing by verifying that the sender domain is consistent across all + authentication layers. Only one of the following lines needs to pass. +

+ {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
+ {#each headerAnalysis.domain_alignment.issues as issue} +
+ + {issue} +
+ {/each} +
+ {/if} +
+
+
+
+ SPF +
+
+
+
+ Strict Alignment +
+ + + {spfStrictAligned ? "Pass" : "Fail"} + +
+
Exact domain match
+
+
+ Relaxed Alignment +
+ + + {spfRelaxedAligned ? "Pass" : "Fail"} + +
+
+ Organizational domain match +
+
+
+ From Domain +
+ + {headerAnalysis.domain_alignment.from_domain || "-"} + +
+ {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
+ Org: + + {headerAnalysis.domain_alignment.from_org_domain} + +
+ {/if} +
+
+ Return-Path Domain +
+ + {headerAnalysis.domain_alignment.return_path_domain || + "-"} + +
+ {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
+ Org: + + {headerAnalysis.domain_alignment + .return_path_org_domain} + +
+ {/if} +
+
+ + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
+ {#if dmarcRecord.spf_alignment === "strict"} + + Strict SPF alignment required — Your DMARC policy + requires exact domain match. The Return-Path domain must exactly + match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — Your DMARC policy + allows organizational domain matching. As long as both domains + share the same organizational domain (e.g., mail.example.com + and example.com), SPF alignment can pass. + {/if} +
+ {/if} +
+
+ + {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} + {@const dkim_aligned = + dkim_domain.domain === headerAnalysis.domain_alignment.from_domain} + {@const dkim_relaxed_aligned = + dkim_domain.org_domain === + headerAnalysis.domain_alignment.from_org_domain} +
+
+ DKIM +
+
+
+
+
+ Strict Alignment +
+ + + {dkim_aligned ? "Pass" : "Fail"} + +
+
+ Exact domain match +
+
+
+ Relaxed Alignment +
+ + + {dkim_relaxed_aligned + ? "Pass" + : "Fail"} + +
+
+ Organizational domain match +
+
+
+ From Domain +
+ {headerAnalysis.domain_alignment.from_domain || + "-"} +
+ {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
+ Org: {headerAnalysis.domain_alignment + .from_org_domain} +
+ {/if} +
+
+ Signature Domain +
{dkim_domain.domain || "-"}
+ {#if dkim_domain.domain !== dkim_domain.org_domain} +
+ Org: {dkim_domain.org_domain} +
+ {/if} +
+
+ + + {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} + {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
+ {#if dmarcRecord.dkim_alignment === "strict"} + + Strict DKIM alignment required — + Your DMARC policy requires exact domain match. The + DKIM signature domain must exactly match the From + domain for DKIM to pass DMARC alignment. + {:else} + + Relaxed DKIM alignment allowed — + Your DMARC policy allows organizational domain matching. + As long as both domains share the same organizational + domain (e.g., mail.example.com and example.com), + DKIM alignment can pass. + {/if} +
+ {/if} + {/if} +
+
+
+ {/each} +
+
+ {/if} + + {#if headerAnalysis.headers && Object.keys(headerAnalysis.headers).length > 0} +
+
Headers
+
+ + + + + + + + + + + + {#each Object.entries(headerAnalysis.headers).sort((a, b) => { + const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 }; + const aImportance = importanceOrder[a[1].importance || "optional"]; + const bImportance = importanceOrder[b[1].importance || "optional"]; + return aImportance - bImportance; + }) as [name, check]} + + + + + + + + {/each} + +
When?PresentValidValue
+ {name} + + {#if check.importance} + + {check.importance} + + {/if} + + + + {#if check.present && check.valid !== undefined} + + {:else} + - + {/if} + + {check.value || "-"} + {#if check.issues && check.issues.length > 0} + {#each check.issues as issue} +
+ + {issue} +
+ {/each} + {/if} +
+
+
+ {/if} +
+
diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte new file mode 100644 index 0000000..737d025 --- /dev/null +++ b/web/src/lib/components/HistoryTable.svelte @@ -0,0 +1,72 @@ + + +
+ + + + + + + + + + + + {#each tests as test} + goto(`/test/${test.test_id}`)}> + + + + + + + {/each} + +
GradeScoreDomainDate
+ + + {test.score}% + + {#if test.from_domain} + {test.from_domain} + {:else} + - + {/if} + + {formatDate(test.created_at)} + + +
+
+ + diff --git a/web/src/lib/components/Logo.svelte b/web/src/lib/components/Logo.svelte new file mode 100644 index 0000000..6bba400 --- /dev/null +++ b/web/src/lib/components/Logo.svelte @@ -0,0 +1,42 @@ + + + + happyDeliver + + + + + + + + + diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte new file mode 100644 index 0000000..893cae6 --- /dev/null +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -0,0 +1,55 @@ + + +
+
+
+ + {title} +
+ MX +
+
+ {#if description} +

{description}

+ {/if} +
+
+ {#each mxRecords as mx} +
+
+ {#if mx.valid} + Valid + {:else} + Invalid + {/if} +
Host: {mx.host}
+
Priority: {mx.priority}
+
+ {#if mx.error} + {mx.error} + {/if} +
+ {/each} +
+
diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index ab9a6f8..afbc426 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -1,12 +1,25 @@
@@ -26,13 +39,37 @@
- Checking for email every 3 seconds... + {#if fetching || nextfetch === 0} + Looking for new email... + {:else if nextfetch} +
+ + Next inbox check in {nextfetch} second{#if nextfetch > 1}s{/if}... + + {#if nbfetch > 0} + + {/if} +
+ {:else} + Checking for email every 3 seconds... + {/if}
@@ -43,11 +80,11 @@
What we'll check:
-
+
  • - SPF, DKIM, DMARC + SPF, DKIM, DMARC, BIMI
  • DNS Records diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte new file mode 100644 index 0000000..8ed723b --- /dev/null +++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte @@ -0,0 +1,124 @@ + + +{#if ptrRecords && ptrRecords.length > 0} +
    +
    +
    + + Forward-Confirmed Reverse DNS +
    + FCrDNS +
    +
    +

    + Forward-confirmed reverse DNS (FCrDNS) verifies that the PTR hostname resolves back + to the original sender IP. This double-check helps establish sender legitimacy. +

    + {#if senderIp} +
    + Original Sender IP: {senderIp} +
    + {/if} +
    + {#if hasForwardRecords} +
    +
    +
    + PTR Hostname(s): + {#each ptrRecords as ptr} +
    + {ptr} +
    + {/each} +
    +
    + Forward Resolution (A/AAAA): + {#each ptrForwardRecords as ip} + {#if ip === senderIp || !fcrDnsIsValid || showDifferent} +
    + {#if senderIp && ip === senderIp} + Match + {:else} + Different + {/if} + {ip} +
    + {/if} + {/each} + {#if fcrDnsIsValid && differentCount > 0} +
    + +
    + {/if} +
    + {#if fcrDnsIsValid} +
    + + Success: Forward-confirmed reverse DNS is properly configured. + The PTR hostname resolves back to the sender IP. +
    + {:else} +
    + + Warning: The PTR hostname does not resolve back to the sender + IP. This may impact deliverability. +
    + {/if} +
    +
    + {:else} +
    +
    +
    + + Error: PTR hostname(s) found but could not resolve to any IP + addresses. Check your DNS configuration. +
    +
    +
    + {/if} +
    +{/if} diff --git a/web/src/lib/components/PtrRecordsDisplay.svelte b/web/src/lib/components/PtrRecordsDisplay.svelte new file mode 100644 index 0000000..c88d7cd --- /dev/null +++ b/web/src/lib/components/PtrRecordsDisplay.svelte @@ -0,0 +1,85 @@ + + +{#if ptrRecords && ptrRecords.length > 0} +
    +
    +
    + + Reverse DNS +
    + PTR +
    +
    +

    + PTR (pointer record), also known as reverse DNS maps IP addresses back to hostnames. + Having proper PTR records is important as many mail servers verify that the sending + IP has a valid reverse DNS entry. +

    + {#if senderIp} +
    + Sender IP: {senderIp} +
    + {/if} +
    +
    + {#each ptrRecords as ptr} +
    +
    + Found + {ptr} +
    +
    + {/each} + {#if ptrRecords.length > 1} +
    +
    + + Warning: Multiple PTR records found. While not strictly an error, + having multiple PTR records can cause issues with some mail servers. It's recommended + to have exactly one PTR record per IP address. +
    +
    + {/if} +
    +
    +{:else if senderIp} +
    +
    +
    + + Reverse DNS (PTR) +
    + PTR +
    +
    +

    + PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR + records is important for email deliverability. +

    +
    + Sender IP: {senderIp} +
    +
    + + Error: No PTR records found for the sender IP. Contact your email service + provider to configure reverse DNS. +
    +
    +
    +{/if} diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte new file mode 100644 index 0000000..4c2795b --- /dev/null +++ b/web/src/lib/components/RspamdCard.svelte @@ -0,0 +1,153 @@ + + +
    +
    +

    + + + rspamd Analysis + + + {#if rspamd.deliverability_score !== undefined} + + {rspamd.deliverability_score}% + + {/if} + {#if rspamd.deliverability_grade !== undefined} + + {/if} + +

    +
    +
    +
    +
    + Score: + + {rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)} + +
    +
    + Classified as: + + {rspamd.is_spam ? "SPAM" : "HAM"} + +
    +
    + Action: + + {effectiveAction.label} + +
    +
    + + {#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0} +
    +
    + + + + + + + + + + {#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]} + 0 + ? "table-warning" + : symbol.score < 0 + ? "table-success" + : ""} + > + + + + + {/each} + +
    SymbolScoreDescription
    + {symbolName} + {#if symbol.params} + + {symbol.params} + + {/if} + + 0 + ? "text-danger fw-bold" + : symbol.score < 0 + ? "text-success fw-bold" + : "text-muted"} + > + {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} + + {symbol.description ?? ""}
    +
    +
    + {/if} + + {#if rspamd.report} +
    + Raw Report +
    {rspamd.report}
    +
    + {/if} +
    +
    + + diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 65aa706..7a80dc4 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,71 +1,162 @@ -
    +
    -

    - {score.toFixed(1)}/10 -

    -

    {getScoreLabel(score)}

    +
    + {#if reanalyzing} +
    + {:else} + + {/if} +
    +

    + {#if reanalyzing} + Analyzing in progress… + {:else} + {getScoreLabel(grade)} + {/if} +

    Overall Deliverability Score

    {#if summary}
    -
    -
    - Authentication - {summary.authentication_score.toFixed(1)}/3 -
    + -
    -
    - Spam Score - {summary.spam_score.toFixed(1)}/2 -
    + -
    -
    - Blacklists - {summary.blacklist_score.toFixed(1)}/2 -
    + -
    -
    - Content - {summary.content_score.toFixed(1)}/2 -
    + -
    -
    - Headers - {summary.header_score.toFixed(1)}/1 -
    + +
    {/if}
    + + diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index 3d4872c..cc88c23 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -1,5 +1,8 @@ -
    -
    -
    - SpamAssassin Analysis -
    +
    +
    +

    + + + SpamAssassin Analysis + + + {#if spamassassin.deliverability_score !== undefined} + + {spamassassin.deliverability_score}% + + {/if} + {#if spamassassin.deliverability_grade !== undefined} + + {/if} + +

    @@ -30,12 +46,55 @@
    - {#if spamassassin.tests && spamassassin.tests.length > 0} + {#if spamassassin.test_details && Object.keys(spamassassin.test_details).length > 0} +
    +
    + + + + + + + + + + {#each Object.entries(spamassassin.test_details) as [testName, detail]} + 0 + ? "table-warning" + : detail.score < 0 + ? "table-success" + : ""} + > + + + + + {/each} + +
    Test NameScoreDescription
    {testName} + 0 + ? "text-danger fw-bold" + : detail.score < 0 + ? "text-success fw-bold" + : "text-muted"} + > + {detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)} + + {detail.description || ""}
    +
    +
    + {:else if spamassassin.tests && spamassassin.tests.length > 0}
    Tests Triggered:
    {#each spamassassin.tests as test} - {test} + {test} {/each}
    @@ -43,8 +102,11 @@ {#if spamassassin.report}
    - Full Report -
    {spamassassin.report}
    + Raw Report +
    {spamassassin.report}
    {/if}
    @@ -62,4 +124,15 @@ details summary:hover { color: var(--bs-primary); } + + /* Darker table colors in dark mode */ + :global([data-bs-theme="dark"]) .table-warning { + --bs-table-bg: rgba(255, 193, 7, 0.2); + --bs-table-border-color: rgba(255, 193, 7, 0.3); + } + + :global([data-bs-theme="dark"]) .table-success { + --bs-table-bg: rgba(25, 135, 84, 0.2); + --bs-table-border-color: rgba(25, 135, 84, 0.3); + } diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte new file mode 100644 index 0000000..2ebb2c2 --- /dev/null +++ b/web/src/lib/components/SpfRecordsDisplay.svelte @@ -0,0 +1,131 @@ + + +{#if spfRecords && spfRecords.length > 0} +
    +
    +
    + + Sender Policy Framework +
    + SPF +
    +
    +

    + SPF specifies which mail servers are authorized to send emails on behalf of your + domain. Receiving servers check the sender's IP address against your SPF record to + prevent email spoofing. +

    +
    +
    + {#each spfRecords as spf, index} +
    + {#if spf.domain} +
    + Domain: {spf.domain} + {#if index > 0} + Included + {/if} +
    + {/if} +
    + Status: + {#if spf.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if spf.all_qualifier} +
    + All Mechanism Policy: + {#if spf.all_qualifier === "-"} + Strict (-all) + {:else if spf.all_qualifier === "~"} + Softfail (~all) + {:else if spf.all_qualifier === "+"} + Pass (+all) + {:else if spf.all_qualifier === "?"} + Neutral (?all) + {/if} + {#if index === 0 || (index === 1 && spfRecords[0].record?.includes("redirect="))} +
    + {#if spf.all_qualifier === "-"} + All unauthorized servers will be rejected. This is the + recommended strict policy. + {:else if dmarcStrict} + While your DMARC {dmarcRecord?.policy} policy provides some protection, + consider using -all for better security with some + old mailbox providers. + {:else if spf.all_qualifier === "~"} + Unauthorized servers will softfail. Consider using -all for stricter policy, though this rarely affects legitimate + email deliverability. + {:else if spf.all_qualifier === "+"} + All servers are allowed to send email. This severely weakens + email authentication. Use -all for strict policy. + {:else if spf.all_qualifier === "?"} + No statement about unauthorized servers. Use -all for strict policy to prevent spoofing. + {/if} +
    + {/if} +
    + {/if} + {#if spf.record} +
    + Record:
    + {spf.record} +
    + {/if} + {#if spf.error} +
    + + {spf.valid ? "Warning:" : "Error:"} + {spf.error} +
    + {/if} +
    + {/each} +
    +
    +{/if} diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte new file mode 100644 index 0000000..518e996 --- /dev/null +++ b/web/src/lib/components/SummaryCard.svelte @@ -0,0 +1,607 @@ + + +
    +
    +
    + + Summary +
    +

    + {#each summarySegments as segment} + {#if segment.link} + + {segment.text} + + {:else if segment.highlight} + + {segment.text} + + {:else} + {segment.text} + {/if} + {/each} + Overall, your email received a grade {#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: + you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: + you could have delivery issues with common providers.{:else if report.grade == "F"}: + it will most likely be rejected by most providers.{:else}!{/if} Check the details below + 🔽 +

    + {@render children?.()} +
    +
    + + diff --git a/web/src/lib/components/TinySurvey.svelte b/web/src/lib/components/TinySurvey.svelte new file mode 100644 index 0000000..805af0e --- /dev/null +++ b/web/src/lib/components/TinySurvey.svelte @@ -0,0 +1,96 @@ + + +{#if $appConfig.survey_url} +
    + {#if step === 0} + {#if question}{@render question()}{:else} +

    Help us to design a better tool, rate this report!

    + {/if} +
    + {#each [...Array(5).keys()] as i} + + {/each} +
    + {:else if step === 1} +

    + {#if responses.stars == 5}Thank you! Would you like to tell us more? + {:else if responses.stars == 4}What are we missing to earn 5 stars? + {:else}How could we improve? + {/if} +

    + + + + {:else if step === 2} +

    + Thank you so much for taking the time to share your feedback! +

    + {/if} +
    +{/if} diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte new file mode 100644 index 0000000..13fd86b --- /dev/null +++ b/web/src/lib/components/WhitelistCard.svelte @@ -0,0 +1,62 @@ + + +
    +
    +

    + + + Whitelist Checks + + Informational +

    +
    +
    +

    + DNS whitelists identify trusted senders. Being listed here is a positive signal, but has + no impact on the overall score. +

    + +
    + {#each Object.entries(whitelists) as [ip, checks]} +
    +
    + + {ip} +
    + + + {#each checks as check} + + + + + {/each} + +
    + + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Not listed"} + + {check.rbl}
    +
    + {/each} +
    +
    +
    diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index 8da4188..a593801 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -1,8 +1,28 @@ // Component exports -export { default as FeatureCard } from "./FeatureCard.svelte"; -export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; -export { default as ScoreCard } from "./ScoreCard.svelte"; -export { default as CheckCard } from "./CheckCard.svelte"; -export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; +export { default as AuthenticationCard } from "./AuthenticationCard.svelte"; +export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte"; +export { default as BlacklistCard } from "./BlacklistCard.svelte"; +export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; +export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; +export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; +export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; +export { default as EmailPathCard } from "./EmailPathCard.svelte"; +export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; +export { default as FeatureCard } from "./FeatureCard.svelte"; +export { default as GradeDisplay } from "./GradeDisplay.svelte"; +export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; +export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; +export { default as Logo } from "./Logo.svelte"; +export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte"; export { default as PendingState } from "./PendingState.svelte"; +export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; +export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; +export { default as ScoreCard } from "./ScoreCard.svelte"; +export { default as RspamdCard } from "./RspamdCard.svelte"; +export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; +export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; +export { default as SummaryCard } from "./SummaryCard.svelte"; +export { default as HistoryTable } from "./HistoryTable.svelte"; +export { default as TinySurvey } from "./TinySurvey.svelte"; +export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/lib/hey-api.ts b/web/src/lib/hey-api.ts index e75e70a..6983e5d 100644 --- a/web/src/lib/hey-api.ts +++ b/web/src/lib/hey-api.ts @@ -7,8 +7,8 @@ export class NotAuthorizedError extends Error { } } -async function customFetch(url: string, init: RequestInit): Promise { - const response = await fetch(url, init); +async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); if (response.status === 400) { const json = await response.json(); diff --git a/web/src/lib/score.ts b/web/src/lib/score.ts new file mode 100644 index 0000000..e9d9bae --- /dev/null +++ b/web/src/lib/score.ts @@ -0,0 +1,5 @@ +export function getScoreColorClass(percentage: number): string { + if (percentage >= 85) return "success"; + if (percentage >= 50) return "warning"; + return "danger"; +} diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts new file mode 100644 index 0000000..962868c --- /dev/null +++ b/web/src/lib/stores/config.ts @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { writable } from "svelte/store"; + +interface AppConfig { + report_retention?: number; + survey_url?: string; + custom_logo_url?: string; + rbls?: string[]; + test_list_enabled?: boolean; +} + +const defaultConfig: AppConfig = { + report_retention: 0, + survey_url: "", + rbls: [], +}; + +function getConfigFromScriptTag(): AppConfig | null { + if (typeof document !== "undefined") { + const configScript = document.getElementById("app-config"); + if (configScript) { + try { + return JSON.parse(configScript.textContent || ""); + } catch (e) { + console.error("Failed to parse app config:", e); + } + } + } + return null; +} + +const initialConfig = getConfigFromScriptTag() || defaultConfig; + +export const appConfig = writable(initialConfig); diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts new file mode 100644 index 0000000..ea24293 --- /dev/null +++ b/web/src/lib/stores/theme.ts @@ -0,0 +1,41 @@ +// 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 . + +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; + +const getInitialTheme = () => { + if (!browser) return "light"; + + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") return stored; + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +export const theme = writable<"light" | "dark">(getInitialTheme()); + +theme.subscribe((value) => { + if (browser) { + localStorage.setItem("theme", value); + document.documentElement.setAttribute("data-bs-theme", value); + } +}); diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 5d0514c..0e103e5 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,8 +1,9 @@ @@ -55,96 +28,5 @@
    -
    -
    - -
    - -
    - - -

    {status}

    - - -

    {getErrorTitle(status)}

    - - -

    {getErrorDescription(status)}

    - - - {#if message !== getErrorDescription(status)} - - {/if} - - -
    - - - Go Home - - -
    - - - {#if status === 404} -
    -

    Looking for something specific?

    - -
    - {/if} -
    -
    +
    - - diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 9ed83d4..92bb4db 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,24 +1,68 @@ + + + +
    -
    + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 8da8dc2..b9259fe 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,10 +1,30 @@ - happyDeliver - Email Deliverability Testing + happyDeliver. Test Your Email Deliverability. -
    +
    @@ -114,7 +170,7 @@ and more. Open-source, self-hosted, and privacy-focused.

    + +{#if $appConfig.test_list_enabled && recentTests.length > 0} +
    +
    +
    +
    +

    Recently Tested

    +

    Latest deliverability reports from this instance

    +
    +
    + + +
    +
    +{/if} + -
    +

    Comprehensive Email Analysis

    - Your favorite deliverability tester, open-source and - self-hostable for complete privacy and control. + Your favorite deliverability tester, open-source and self-hostable for complete + privacy and control.

    -
    +
    {#each features as feature}
    @@ -162,7 +244,7 @@
    -
    +
    @@ -196,15 +278,56 @@ {/if}
    + +
    diff --git a/web/src/routes/blacklist/+page.svelte b/web/src/routes/blacklist/+page.svelte new file mode 100644 index 0000000..d2946b8 --- /dev/null +++ b/web/src/routes/blacklist/+page.svelte @@ -0,0 +1,197 @@ + + + + Blacklist Check - happyDeliver + + +
    +
    +
    + +
    +

    + + Check IP Blacklist Status +

    +

    + Test an IP address against multiple DNS-based blacklists (RBLs) to check its + reputation. +

    +
    + + +
    +
    +

    Enter IP Address

    +
    + + + + + +
    + + {#if error} + + {/if} + + + + Enter an IPv4 address (e.g., 192.0.2.1) or IPv6 address (e.g., 2001:db8::1) + +
    +
    + + +
    +
    +
    +
    +

    + + What's Checked +

    +
      + {#each $appConfig.rbls as rbl} +
    • + {rbl} +
    • + {/each} +
    +
    +
    +
    + +
    +
    +
    +

    + + Why Check Blacklists? +

    +

    + DNS-based blacklists (RBLs) are used by email servers to identify + and block spam sources. Being listed can severely impact email + deliverability. +

    +

    + This tool checks your IP against multiple popular RBLs to help you: +

    +
      +
    • + Monitor IP reputation +
    • +
    • + Identify deliverability + issues +
    • +
    • + Take corrective action +
    • +
    +
    +
    +
    +
    + + +
    +

    + + Need Complete Email Analysis? +

    +

    + For comprehensive deliverability testing including DKIM verification, content + analysis, spam scoring, and more: +

    + + + Send Test Email + +
    +
    +
    +
    + + diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte new file mode 100644 index 0000000..89676ed --- /dev/null +++ b/web/src/routes/blacklist/[ip]/+page.svelte @@ -0,0 +1,272 @@ + + + + {ip} - Blacklist Check - happyDeliver + + +
    +
    +
    + +
    +
    +

    + + Blacklist Analysis +

    + + + Check Another IP + +
    +
    + + {#if loading} + +
    +
    +
    + Loading... +
    +

    Checking {ip}...

    +

    Querying DNS-based blacklists

    +
    +
    + {:else if error} + +
    +
    + +

    Check Failed

    +

    {error}

    + +
    +
    + {:else if result} + +
    + +
    +
    +
    +
    +

    + {result.ip} +

    + {#if result.listed_count === 0} +
    + + Not Listed +

    + This IP address is not listed on any checked + blacklists. +

    +
    + {:else} +
    + + Listed on {result.listed_count} blacklist{result.listed_count > + 1 + ? "s" + : ""} +

    + This IP address is listed on {result.listed_count} of + {result.blacklists.length} checked blacklist{result + .blacklists.length > 1 + ? "s" + : ""}. +

    +
    + {/if} +
    +
    +
    + + Blacklist Score +
    +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    + + + {#if result.whitelists && result.whitelists.length > 0} +
    + +
    + {/if} +
    + + +
    +
    +

    + + What This Means +

    + {#if result.listed_count === 0} +

    + Good news! This IP address is not currently listed + on any of the checked DNS-based blacklists (RBLs). This indicates + a good sender reputation and should not negatively impact email deliverability. +

    + {:else} +

    + Warning: This IP address is listed on {result.listed_count} + blacklist{result.listed_count > 1 ? "s" : ""}. Being listed can + significantly impact email deliverability as many mail servers + use these blacklists to filter incoming mail. +

    +
    +

    Recommended Actions:

    +
      +
    • + Investigate the cause of the listing (compromised + system, spam complaints, etc.) +
    • +
    • + Fix any security issues or stop sending practices that + led to the listing +
    • +
    • + Request delisting from each RBL (check their websites + for removal procedures) +
    • +
    • + Monitor your IP reputation regularly to prevent future + listings +
    • +
    +
    + {/if} +
    +
    + + +
    +
    +

    + + Want Complete Email Analysis? +

    +

    + This blacklist check tests IP reputation only. For comprehensive + deliverability testing including DKIM verification, content + analysis, spam scoring, and DNS configuration: +

    + + + Send Test Email + +
    +
    +
    + {/if} +
    +
    +
    + + diff --git a/web/src/routes/domain/+page.svelte b/web/src/routes/domain/+page.svelte new file mode 100644 index 0000000..df67f4e --- /dev/null +++ b/web/src/routes/domain/+page.svelte @@ -0,0 +1,187 @@ + + + + Domain Test - happyDeliver + + +
    +
    +
    + +
    +

    + + Test Domain Configuration +

    +

    + Check your domain's email DNS records (MX, SPF, DMARC, BIMI) without sending an + email. +

    +
    + + +
    +
    +

    Enter Domain Name

    +
    + + + + + +
    + + {#if error} + + {/if} + + + + Enter a domain name like "example.com" or "mail.example.org" + +
    +
    + + +
    +
    +
    +
    +

    + + What's Checked +

    +
      +
    • + MX Records +
    • +
    • + SPF Records +
    • +
    • + DMARC Policy +
    • +
    • + BIMI Support +
    • +
    • + Disposable Domain Check +
    • +
    +
    +
    +
    + +
    +
    +
    +

    + + Need More? +

    +

    + For complete email deliverability analysis including: +

    +
      +
    • + DKIM Verification +
    • +
    • + Content & Header Analysis +
    • +
    • + Spam Scoring +
    • +
    • + Blacklist Checks +
    • +
    + + + Send Test Email + +
    +
    +
    +
    +
    +
    +
    + + diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte new file mode 100644 index 0000000..d866e21 --- /dev/null +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -0,0 +1,190 @@ + + + + {domain} - Domain Test - happyDeliver + + +
    +
    +
    + +
    +
    +

    + + Domain Analysis +

    + + + Test Another Domain + +
    +
    + + {#if loading} + +
    +
    +
    + Loading... +
    +

    Analyzing {domain}...

    +

    Checking DNS records and configuration

    +
    +
    + {:else if error} + +
    +
    + +

    Analysis Failed

    +

    {error}

    + +
    +
    + {:else if result} + +
    + +
    +
    +
    +
    +

    + {result.domain} +

    + {#if result.is_disposable} +
    + + Disposable Email Provider Detected +

    + This domain is a known temporary/disposable email + service. Emails from this domain may have lower + deliverability. +

    +
    + {:else} +

    Domain Configuration Score

    + {/if} +
    +
    +
    + + DNS +
    +
    +
    +
    + +
    +
    +
    + + + + + +
    +
    +

    + + Want Complete Email Analysis? +

    +

    + This domain-only test checks DNS configuration. For comprehensive + deliverability testing including DKIM verification, content + analysis, spam scoring, and blacklist checks: +

    + + + Send a Test Email + +
    +
    +
    + {/if} +
    +
    +
    + + diff --git a/web/src/routes/history/+page.svelte b/web/src/routes/history/+page.svelte new file mode 100644 index 0000000..6925ab8 --- /dev/null +++ b/web/src/routes/history/+page.svelte @@ -0,0 +1,189 @@ + + + + Test History - happyDeliver + + +
    +
    +
    +
    +

    + + Test History +

    + +
    + + {#if loading} +
    +
    + Loading... +
    +

    Loading tests...

    +
    + {:else if error} + + {:else if tests.length === 0} +
    + +

    No tests yet

    +

    + Send a test email to get your first deliverability + report. +

    + +
    + {:else} + + + + {#if totalPages > 1} + + {/if} + {/if} +
    +
    +
    diff --git a/web/src/routes/test/+page.ts b/web/src/routes/test/+page.ts new file mode 100644 index 0000000..8f8fd5b --- /dev/null +++ b/web/src/routes/test/+page.ts @@ -0,0 +1,22 @@ +import { error, redirect, type Load } from "@sveltejs/kit"; + +import { createTest as apiCreateTest } from "$lib/api"; + +export const prerender = false; +export const ssr = false; + +export const load: Load = async ({}) => { + let response; + try { + response = await apiCreateTest(); + } catch (err) { + const errorObj = err as { response?: { status?: number }; message?: string }; + error(errorObj.response?.status || 500, errorObj.message || "Unknown error"); + } + + if (response.response.ok && response.data) { + redirect(302, `/test/${response.data.id}`); + } else { + error(response.response.status, response.error); + } +}; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index f70bc53..113209d 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,18 +1,86 @@ - {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."} + + {report + ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}` + : test + ? `Test ${test.id.slice(0, 7)}` + : "Loading..."} - happyDeliver +
    @@ -72,50 +216,212 @@

    Loading test...

    {:else if error} -
    -
    - -
    -
    + {:else if test && test.status !== "analyzed"} - + fetchTest()} + /> {:else if report}
    -
    +
    - +
    +
    + +
    +
    +
    - +
    -

    Detailed Checks

    - {#each report.checks as check} - - {/each} + +
    + +
    +
    - - {#if report.spamassassin} -
    + + {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} +
    - +
    {/if} - + + {#if report.dns_results} +
    +
    + +
    +
    + {/if} + + + {#if report.authentication} +
    +
    + +
    +
    + {/if} + + + {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} + + {/snippet} + + + {#snippet whitelistChecks(whitelists: BlacklistRecords)} + + {/snippet} + + + {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} +
    +
    + {@render blacklistChecks(report.blacklists, report)} +
    +
    + {@render whitelistChecks(report.whitelists)} +
    +
    + {:else} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
    +
    + {@render blacklistChecks(report.blacklists, report)} +
    +
    + {/if} + + {#if report.whitelists && Object.keys(report.whitelists).length > 0} +
    +
    + {@render whitelistChecks(report.whitelists)} +
    +
    + {/if} + {/if} + + + {#if report.header_analysis} + + {/if} + + + {#if report.spamassassin || report.rspamd} +
    + {#if report.spamassassin} +
    + +
    + {/if} + {#if report.rspamd} +
    + +
    + {/if} +
    + {/if} + + + {#if report.content_analysis} +
    +
    + +
    +
    + {/if} + +
    - + Test Another Email @@ -140,4 +446,51 @@ transform: translateY(0); } } + + .menu-container { + position: relative; + } + + .menu-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.25rem; + background: white; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + min-width: 250px; + z-index: 1000; + padding: 0.5rem 0; + } + + .menu-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + background: none; + border: none; + text-align: left; + color: #212529; + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease-in-out; + font-size: 1rem; + } + + .menu-item:hover:not(:disabled) { + background-color: #f8f9fa; + } + + .menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .menu-divider { + margin: 0.5rem 0; + border: 0; + border-top: 1px solid #dee2e6; + } diff --git a/web/src/routes/test/[test]/+page.ts b/web/src/routes/test/[test]/+page.ts new file mode 100644 index 0000000..ae88a27 --- /dev/null +++ b/web/src/routes/test/[test]/+page.ts @@ -0,0 +1,2 @@ +export const prerender = false; +export const ssr = false; diff --git a/web/static/img/og.webp b/web/static/img/og.webp new file mode 100644 index 0000000..986dda5 Binary files /dev/null and b/web/static/img/og.webp differ diff --git a/web/static/img/report.webp b/web/static/img/report.webp new file mode 100644 index 0000000..d3df7a9 Binary files /dev/null and b/web/static/img/report.webp differ