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

More

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

Text

More

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

HTML text

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

Test

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

test

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

- - - 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 deleted file mode 100644 index 889e24f..0000000 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -{#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 deleted file mode 100644 index bb80acb..0000000 --- a/web/src/lib/components/BlacklistCard.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
-
-

- - - 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 new file mode 100644 index 0000000..bc5741c --- /dev/null +++ b/web/src/lib/components/CheckCard.svelte @@ -0,0 +1,74 @@ + + +
+
+
+
+ +
+
+
+
+
{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 deleted file mode 100644 index 51c4e5b..0000000 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ /dev/null @@ -1,199 +0,0 @@ - - -
-
-

- - - 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 deleted file mode 100644 index 11a1b00..0000000 --- a/web/src/lib/components/DkimRecordsDisplay.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
-
-
- - 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 deleted file mode 100644 index e2b83f0..0000000 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ /dev/null @@ -1,425 +0,0 @@ - - -{#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 deleted file mode 100644 index 6dabe0b..0000000 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ /dev/null @@ -1,177 +0,0 @@ - - -
-
-

- - - 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 5b5f051..aa79f9e 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -1,13 +1,10 @@ -
-
- +
+
+ {email}
{#if copied} - + Copied to clipboard! {/if} diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte deleted file mode 100644 index a4fda45..0000000 --- a/web/src/lib/components/EmailPathCard.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#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 deleted file mode 100644 index 96cfae2..0000000 --- a/web/src/lib/components/ErrorDisplay.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
-
- -
- -
- - -

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

- - - 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 deleted file mode 100644 index 737d025..0000000 --- a/web/src/lib/components/HistoryTable.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- - - - - - - - - - - - {#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 deleted file mode 100644 index 6bba400..0000000 --- a/web/src/lib/components/Logo.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - - happyDeliver - - - - - - - - - diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte deleted file mode 100644 index 893cae6..0000000 --- a/web/src/lib/components/MxRecordsDisplay.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
-
-
- - {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 afbc426..ab9a6f8 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -1,25 +1,12 @@
@@ -39,37 +26,13 @@
- {#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} + Checking for email every 3 seconds...
@@ -80,11 +43,11 @@
What we'll check:
-
+
  • - SPF, DKIM, DMARC, BIMI + SPF, DKIM, DMARC
  • DNS Records diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte deleted file mode 100644 index 8ed723b..0000000 --- a/web/src/lib/components/PtrForwardRecordsDisplay.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - -{#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 deleted file mode 100644 index c88d7cd..0000000 --- a/web/src/lib/components/PtrRecordsDisplay.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -{#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 deleted file mode 100644 index 4c2795b..0000000 --- a/web/src/lib/components/RspamdCard.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
    -
    -

    - - - 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 7a80dc4..65aa706 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,162 +1,71 @@ -
    +
    -
    - {#if reanalyzing} -
    - {:else} - - {/if} -
    -

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

    +

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

    +

    {getScoreLabel(score)}

    Overall Deliverability Score

    {#if summary}
    -
    - -
    - - DNS -
    -
    +
    +
    + Authentication + {summary.authentication_score.toFixed(1)}/3 +
    -
    - -
    - - Authentication -
    -
    +
    +
    + Spam Score + {summary.spam_score.toFixed(1)}/2 +
    -
    - -
    - - Blacklists -
    -
    +
    +
    + Blacklists + {summary.blacklist_score.toFixed(1)}/2 +
    -
    - -
    - - Headers -
    -
    +
    +
    + Content + {summary.content_score.toFixed(1)}/2 +
    - -
    - -
    - - Content -
    -
    +
    +
    + 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 cc88c23..3d4872c 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -1,8 +1,5 @@ -
    -
    -

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

    +
    +
    +
    + SpamAssassin Analysis +
    @@ -46,55 +30,12 @@
    - {#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} + {#if spamassassin.tests && spamassassin.tests.length > 0}
    Tests Triggered:
    {#each spamassassin.tests as test} - {test} + {test} {/each}
    @@ -102,11 +43,8 @@ {#if spamassassin.report}
    - Raw Report -
    {spamassassin.report}
    + Full Report +
    {spamassassin.report}
    {/if}
    @@ -124,15 +62,4 @@ 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 deleted file mode 100644 index 2ebb2c2..0000000 --- a/web/src/lib/components/SpfRecordsDisplay.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -{#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 deleted file mode 100644 index 518e996..0000000 --- a/web/src/lib/components/SummaryCard.svelte +++ /dev/null @@ -1,607 +0,0 @@ - - -
    -
    -
    - - 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 deleted file mode 100644 index 805af0e..0000000 --- a/web/src/lib/components/TinySurvey.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -{#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 deleted file mode 100644 index 13fd86b..0000000 --- a/web/src/lib/components/WhitelistCard.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
    -
    -

    - - - 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 a593801..8da4188 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -1,28 +1,8 @@ // Component exports -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 CheckCard } from "./CheckCard.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"; +export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; +export { default as PendingState } from "./PendingState.svelte"; diff --git a/web/src/lib/hey-api.ts b/web/src/lib/hey-api.ts index 6983e5d..e75e70a 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(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init); +async function customFetch(url: string, init: RequestInit): Promise { + const response = await fetch(url, init); if (response.status === 400) { const json = await response.json(); diff --git a/web/src/lib/score.ts b/web/src/lib/score.ts deleted file mode 100644 index e9d9bae..0000000 --- a/web/src/lib/score.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 962868c..0000000 --- a/web/src/lib/stores/config.ts +++ /dev/null @@ -1,54 +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 . - -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 deleted file mode 100644 index ea24293..0000000 --- a/web/src/lib/stores/theme.ts +++ /dev/null @@ -1,41 +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 . - -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 0e103e5..5d0514c 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,9 +1,8 @@ @@ -28,5 +55,96 @@
    - +
    +
    + +
    + +
    + + +

    {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 92bb4db..9ed83d4 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,68 +1,24 @@ - - - -
    -
    - - diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b9259fe..8da8dc2 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,30 +1,10 @@ - happyDeliver. Test Your Email Deliverability. + happyDeliver - Email Deliverability Testing -
    +
    @@ -170,7 +114,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}
    @@ -244,7 +162,7 @@
    -
    +
    @@ -278,56 +196,15 @@ {/if}
    - -
    diff --git a/web/src/routes/blacklist/+page.svelte b/web/src/routes/blacklist/+page.svelte deleted file mode 100644 index d2946b8..0000000 --- a/web/src/routes/blacklist/+page.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - - - 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 deleted file mode 100644 index 89676ed..0000000 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ /dev/null @@ -1,272 +0,0 @@ - - - - {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 deleted file mode 100644 index df67f4e..0000000 --- a/web/src/routes/domain/+page.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - - - 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 deleted file mode 100644 index d866e21..0000000 --- a/web/src/routes/domain/[domain]/+page.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - - - {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 deleted file mode 100644 index 6925ab8..0000000 --- a/web/src/routes/history/+page.svelte +++ /dev/null @@ -1,189 +0,0 @@ - - - - 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 deleted file mode 100644 index 8f8fd5b..0000000 --- a/web/src/routes/test/+page.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 113209d..f70bc53 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,86 +1,18 @@ - - {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 - + {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."}
    @@ -216,212 +72,50 @@

    Loading test...

    {:else if error} - +
    +
    + +
    +
    {:else if test && test.status !== "analyzed"} - fetchTest()} - /> + {:else if report}
    -
    -
    -
    -
    - -
    -
    - -
    -
    - -
    - -
    - -
    -
    +
    - - {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} -
    -
    - -
    + +
    +
    +

    Detailed Checks

    + {#each report.checks as check} + + {/each}
    - {/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} + + {#if report.spamassassin}
    -
    - {@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 @@ -446,51 +140,4 @@ 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 deleted file mode 100644 index ae88a27..0000000 --- a/web/src/routes/test/[test]/+page.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const prerender = false; -export const ssr = false; diff --git a/web/static/img/og.webp b/web/static/img/og.webp deleted file mode 100644 index 986dda5..0000000 Binary files a/web/static/img/og.webp and /dev/null differ diff --git a/web/static/img/report.webp b/web/static/img/report.webp deleted file mode 100644 index d3df7a9..0000000 Binary files a/web/static/img/report.webp and /dev/null differ