diff --git a/.drone.yml b/.drone.yml index 053beb0..779952f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,7 +9,7 @@ platform: steps: - name: frontend - image: node:22-alpine + image: node:24-alpine commands: - cd web - npm install --network-timeout=100000 @@ -21,7 +21,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -35,7 +35,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -47,7 +47,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin @@ -61,7 +61,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin diff --git a/.gitignore b/.gitignore index 7ece05e..e943630 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/models.gen.go -internal/api/server.gen.go \ No newline at end of file +internal/api/server.gen.go +internal/model/types.gen.go diff --git a/Dockerfile b/Dockerfile index 6e099f6..4568784 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA # Stage 1: Build the Svelte application -FROM node:22-alpine AS nodebuild +FROM node:24-alpine AS nodebuild WORKDIR /build @@ -34,7 +34,7 @@ RUN go generate ./... && \ # Stage 3: Prepare perl and spamass-milt FROM alpine:3 AS pl -RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ apk add --no-cache \ build-base \ libmilter-dev \ @@ -55,7 +55,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a perl-json-xs \ perl-list-moreutils \ perl-moose \ - perl-net-idn-encode@testing \ + perl-net-idn-encode@edge \ perl-net-ssleay \ perl-netaddr-ip \ perl-package-stash \ @@ -86,7 +86,7 @@ RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milt FROM alpine:3 # Install all required packages -RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ apk add --no-cache \ bash \ ca-certificates \ @@ -106,7 +106,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a perl-json-xs \ perl-list-moreutils \ perl-moose \ - perl-net-idn-encode@testing \ + perl-net-idn-encode@edge \ perl-net-ssleay \ perl-netaddr-ip \ perl-package-stash \ @@ -121,6 +121,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a perl-xml-libxml \ postfix \ postfix-pcre \ + rspamd \ spamassassin \ spamassassin-client \ supervisor \ @@ -143,8 +144,11 @@ RUN mkdir -p /etc/happydeliver \ /var/lib/authentication_milter \ /var/spool/postfix/authentication_milter \ /var/spool/postfix/spamassassin \ + /var/spool/postfix/rspamd \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ + && chown rspamd:mail /var/spool/postfix/rspamd \ + && chmod 750 /var/spool/postfix/rspamd # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -154,6 +158,7 @@ RUN chmod +x /usr/local/bin/happyDeliver COPY docker/postfix/ /etc/postfix/ COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/spamassassin/ /etc/mail/spamassassin/ +COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh @@ -165,11 +170,21 @@ RUN chmod +x /entrypoint.sh EXPOSE 25 8080 # Default configuration -ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net +ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ + HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \ + HAPPYDELIVER_DOMAIN=happydeliver.local \ + HAPPYDELIVER_ADDRESS_PREFIX=test- \ + HAPPYDELIVER_DNS_TIMEOUT=5s \ + HAPPYDELIVER_HTTP_TIMEOUT=10s \ + HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334 # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1 + # Set entrypoint ENTRYPOINT ["/entrypoint.sh"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/README.md b/README.md index e40a791..4010d7e 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,15 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration - **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings +![A sample deliverability report](web/static/img/report.webp) + ## Quick Start ### With Docker (Recommended) @@ -24,6 +26,7 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha - **Postfix MTA**: Receives emails on port 25 - **authentication_milter**: Entreprise grade email authentication - **SpamAssassin**: Spam scoring and analysis +- **rspamd**: Second spam filter for cross-validated scoring - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports @@ -35,7 +38,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git cd happydeliver # Edit docker-compose.yml to set your domain -# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables +# Change HAPPYDELIVER_DOMAIN environment variable and hostname # Build and start docker-compose up -d @@ -61,12 +64,86 @@ docker run -d \ -p 25:25 \ -p 8080:8080 \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - -e HOSTNAME=mail.yourdomain.com \ + --hostname mail.yourdomain.com \ -v $(pwd)/data:/var/lib/happydeliver \ -v $(pwd)/logs:/var/log/happydeliver \ happydeliver:latest ``` +#### 3. Configure TLS Certificates (Optional but Recommended) + +To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments. + +##### Using docker-compose + +Add the certificate paths to your `docker-compose.yml`: + +```yaml +environment: + - POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt + - POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key +volumes: + - /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro + - /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro +``` + +##### Using docker run + +```bash +docker run -d \ + --name happydeliver \ + -p 25:25 \ + -p 8080:8080 \ + -e HAPPYDELIVER_DOMAIN=yourdomain.com \ + -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \ + -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \ + --hostname mail.yourdomain.com \ + -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \ + -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \ + -v $(pwd)/data:/var/lib/happydeliver \ + -v $(pwd)/logs:/var/log/happydeliver \ + happydeliver:latest +``` + +**Notes:** +- The certificate file should contain the full certificate chain (certificate + intermediate CAs) +- The private key file must be readable by the postfix user inside the container +- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required) +- If both environment variables are not set, Postfix will run without TLS support + +#### 4. Configure Network and DNS + +##### Open SMTP Port + +Port 25 (SMTP) must be accessible from the internet to receive test emails: + +```bash +# Check if port 25 is listening +netstat -ln | grep :25 + +# Allow port 25 through firewall (example with ufw) +sudo ufw allow 25/tcp + +# For iptables +sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT +``` + +**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support. + +##### Configure DNS Records + +Point your domain to the server's IP address. + +``` +yourdomain.com. IN A 203.0.113.10 +yourdomain.com. IN AAAA 2001:db8::10 +``` + +Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly. + +There is no need for an MX record here since the same host will serve both HTTP and SMTP. + + ### Manual Build #### 1. Build @@ -86,10 +163,27 @@ The server will start on `http://localhost:8080` by default. #### 3. Integrate with your existing e-mail setup -It is expected your setup annotate the email with eg. opendkim, spamassassin, ... +It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -Choose one of the following way to integrate happyDeliver in your existing setup: +#### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). + +If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: + +```bash +./happyDeliver server -receiver-hostname mail.example.com +``` + +Or via environment variable: +```bash +HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server +``` + +**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. #### Postfix LMTP Transport @@ -185,6 +279,33 @@ 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: @@ -193,7 +314,7 @@ The deliverability score is calculated from A to F based on: - **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation - **Blacklist**: RBL/DNSBL checks - **Headers**: Required headers, MIME structure, Domain alignment -- **Spam**: SpamAssassin score +- **Spam**: SpamAssassin and rspamd scores (combined 50/50) - **Content**: HTML quality, links, images, unsubscribe ## Funding diff --git a/api/config-models.yaml b/api/config-models.yaml index 9c3425c..aa2fb0e 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,5 +1,9 @@ -package: api +package: model generate: models: true - embedded-spec: false -output: internal/api/models.gen.go + embedded-spec: true +output: internal/model/types.gen.go +output-options: + skip-prune: true +import-mapping: + ./schemas.yaml: "-" diff --git a/api/config-server.yaml b/api/config-server.yaml index 20f8daf..347dbaf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,5 +1,8 @@ package: api generate: gin-server: true + models: true embedded-spec: true output: internal/api/server.gen.go +import-mapping: + ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index f5eb96a..2dbf304 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -76,6 +76,49 @@ paths: schema: $ref: '#/components/schemas/Error' + /tests: + get: + tags: + - tests + summary: List all tests + description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration. + operationId: listTests + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Number of items to skip + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Maximum number of items to return + responses: + '200': + description: List of test summaries + content: + application/json: + schema: + $ref: '#/components/schemas/TestListResponse' + '403': + description: Test listing is disabled + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /report/{id}: get: tags: @@ -169,6 +212,72 @@ paths: 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: @@ -187,928 +296,74 @@ 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" - + $ref: './schemas.yaml#/components/schemas/Test' 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" - + $ref: './schemas.yaml#/components/schemas/TestResponse' 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' - 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 - 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 - + $ref: './schemas.yaml#/components/schemas/Report' 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: SpamAssassin score (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" - + $ref: './schemas.yaml#/components/schemas/ScoreSummary' 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"] - + $ref: './schemas.yaml#/components/schemas/ContentAnalysis' 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" - + $ref: './schemas.yaml#/components/schemas/ContentIssue' 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 - + $ref: './schemas.yaml#/components/schemas/LinkCheck' 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 - + $ref: './schemas.yaml#/components/schemas/ImageCheck' 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 - + $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' 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"] - + $ref: './schemas.yaml#/components/schemas/HeaderCheck' 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" - + $ref: './schemas.yaml#/components/schemas/ReceivedHop' + DKIMDomainInfo: + $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' 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: - type: string - description: Domains from DKIM signatures - example: ["example.com"] - 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"] - + $ref: './schemas.yaml#/components/schemas/DomainAlignment' 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" - + $ref: './schemas.yaml#/components/schemas/HeaderIssue' AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - 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) - + $ref: './schemas.yaml#/components/schemas/AuthenticationResults' AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none, neutral, softfail, temperror, permerror, declined] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - + $ref: './schemas.yaml#/components/schemas/AuthResult' ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - + $ref: './schemas.yaml#/components/schemas/ARCResult' IPRevResult: - 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)" - + $ref: './schemas.yaml#/components/schemas/IPRevResult' SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - - test_details - properties: - 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 - + $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' 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 - description: - type: string - description: Human-readable description of what this test checks - example: "Bayes spam probability is 0 to 1%" - + $ref: './schemas.yaml#/components/schemas/SpamTestDetail' + RspamdResult: + $ref: './schemas.yaml#/components/schemas/RspamdResult' 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 - + $ref: './schemas.yaml#/components/schemas/DNSResults' 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" - + $ref: './schemas.yaml#/components/schemas/MXRecord' 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" - + $ref: './schemas.yaml#/components/schemas/SPFRecord' 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..." - 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" - + $ref: './schemas.yaml#/components/schemas/DKIMRecord' DMARCRecord: - type: object - required: - - valid - properties: - record: - type: string - description: DMARC record content - example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC policy - example: "quarantine" - subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy - example: "quarantine" - percentage: - type: integer - minimum: 0 - maximum: 100 - description: Percentage of messages subjected to filtering (pct tag, default 100) - example: 100 - spf_alignment: - type: string - enum: [relaxed, strict] - description: SPF alignment mode (aspf tag) - example: "relaxed" - dkim_alignment: - type: string - enum: [relaxed, strict] - description: DKIM alignment mode (adkim tag) - example: "relaxed" - valid: - type: boolean - description: Whether the DMARC record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DMARC record found" - + $ref: './schemas.yaml#/components/schemas/DMARCRecord' 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" - + $ref: './schemas.yaml#/components/schemas/BIMIRecord' 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 - + $ref: './schemas.yaml#/components/schemas/BlacklistCheck' Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - + $ref: './schemas.yaml#/components/schemas/Status' Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details + $ref: './schemas.yaml#/components/schemas/Error' + DomainTestRequest: + $ref: './schemas.yaml#/components/schemas/DomainTestRequest' + DomainTestResponse: + $ref: './schemas.yaml#/components/schemas/DomainTestResponse' + BlacklistCheckRequest: + $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' + BlacklistCheckResponse: + $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' + TestSummary: + $ref: './schemas.yaml#/components/schemas/TestSummary' + TestListResponse: + $ref: './schemas.yaml#/components/schemas/TestListResponse' diff --git a/api/schemas.yaml b/api/schemas.yaml new file mode 100644 index 0000000..df0b416 --- /dev/null +++ b/api/schemas.yaml @@ -0,0 +1,1173 @@ +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..." + 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" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" + + BlacklistCheck: + type: object + required: + - rbl + - listed + properties: + rbl: + type: string + description: RBL/DNSBL name + example: "zen.spamhaus.org" + listed: + type: boolean + description: Whether IP is listed + example: false + response: + type: string + description: RBL response code or message + example: "127.0.0.2" + error: + type: string + description: RBL error if any + + Status: + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + + Error: + type: object + required: + - error + - message + properties: + error: + type: string + description: Error code + example: "not_found" + message: + type: string + description: Human-readable error message + example: "Test not found" + details: + type: string + description: Additional error details + + DomainTestRequest: + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + + DomainTestResponse: + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' + + BlacklistCheckRequest: + type: object + required: + - ip + properties: + ip: + type: string + description: IPv4 or IPv6 address to check against blacklists + example: "192.0.2.1" + pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' + + BlacklistCheckResponse: + type: object + required: + - ip + - blacklists + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of blacklist check results + listed_count: + type: integer + description: Number of blacklists that have this IP listed + example: 0 + score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist score (0-100, higher is better) + example: 100 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A+" + whitelists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of DNS whitelist check results (informational only) + + TestSummary: + type: object + required: + - test_id + - score + - grade + - created_at + properties: + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Test identifier (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score (0-100) + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade + from_domain: + type: string + description: Sender domain extracted from the report + created_at: + type: string + format: date-time + + TestListResponse: + type: object + required: + - tests + - total + - offset + - limit + properties: + tests: + type: array + items: + $ref: '#/components/schemas/TestSummary' + total: + type: integer + description: Total number of tests + offset: + type: integer + description: Current offset + limit: + type: integer + description: Current limit diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 01d99f1..3caf4d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -29,13 +29,12 @@ import ( "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/version" ) -const version = "0.1.0-dev" - func main() { - fmt.Println("happyDeliver - Email Deliverability Testing Platform") - fmt.Printf("Version: %s\n", version) + fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform") + fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version) cfg, err := config.ConsolidateConfig() if err != nil { @@ -53,8 +52,20 @@ func main() { if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { log.Fatalf("Analyzer error: %v", err) } + case "backup": + if err := app.RunBackup(cfg); err != nil { + log.Fatalf("Backup error: %v", err) + } + case "restore": + inputFile := "" + if len(flag.Args()) >= 2 { + inputFile = flag.Args()[1] + } + if err := app.RunRestore(cfg, inputFile); err != nil { + log.Fatalf("Restore error: %v", err) + } case "version": - fmt.Println(version) + fmt.Println(version.Version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -64,9 +75,11 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") + fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/docker-compose.yml b/docker-compose.yml index 87521ef..ccfd313 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,14 @@ services: build: context: . dockerfile: Dockerfile - image: happydeliver:latest + image: happydomain/happydeliver:latest container_name: happydeliver + # Set a hostname hostname: mail.happydeliver.local environment: - # Set your domain and hostname - DOMAIN: happydeliver.local - HOSTNAME: mail.happydeliver.local + # Set your domain + HAPPYDELIVER_DOMAIN: happydeliver.local ports: # SMTP port @@ -26,13 +26,6 @@ services: restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - volumes: data: logs: diff --git a/docker/README.md b/docker/README.md index 45cce6b..2199eeb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -109,12 +109,37 @@ Default configuration for the Docker environment: The container accepts these environment variables: -- `DOMAIN`: Email domain for test addresses (default: happydeliver.local) -- `HOSTNAME`: Container hostname (default: mail.happydeliver.local) +- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) +- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP + +### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). + +In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. + +**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: -Example: ```bash -docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ... +docker run -d \ + -e HAPPYDELIVER_DOMAIN=example.com \ + -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ + ... +``` + +To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. + +Example (all-in-one, no override needed): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... +``` + +Example (external MTA integration): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... ``` ## Volumes diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 99744f6..ef45b61 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,7 +4,7 @@ set -e echo "Starting happyDeliver container..." # Get environment variables with defaults -HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" +[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" @@ -15,6 +15,10 @@ mkdir -p /var/spool/postfix/authentication_milter chown mail:mail /var/spool/postfix/authentication_milter chmod 750 /var/spool/postfix/authentication_milter +mkdir -p /var/spool/postfix/rspamd +chown rspamd:mail /var/spool/postfix/rspamd +chmod 750 /var/spool/postfix/rspamd + # Create log directory mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter chown happydeliver:happydeliver /var/log/happydeliver @@ -25,6 +29,15 @@ 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 diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index fcdb75c..5a73fb3 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -28,7 +28,7 @@ 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 +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf new file mode 100644 index 0000000..f3ed60c --- /dev/null +++ b/docker/rspamd/local.d/actions.conf @@ -0,0 +1,5 @@ +no_action = 0; +reject = null; +add_header = null; +rewrite_subject = null; +greylist = null; \ No newline at end of file diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf new file mode 100644 index 0000000..378b8a3 --- /dev/null +++ b/docker/rspamd/local.d/milter_headers.conf @@ -0,0 +1,5 @@ +# Add "extended Rspamd headers" +extended_spam_headers = true; + +skip_local = false; +skip_authenticated = false; \ No newline at end of file diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc new file mode 100644 index 0000000..485d0c9 --- /dev/null +++ b/docker/rspamd/local.d/options.inc @@ -0,0 +1,3 @@ +# rspamd options for happyDeliver +# Disable Bayes learning to keep the setup stateless +use_redis = false; diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc new file mode 100644 index 0000000..04c9a1d --- /dev/null +++ b/docker/rspamd/local.d/worker-proxy.inc @@ -0,0 +1,6 @@ +# Enable rspamd milter proxy worker via Unix socket for Postfix integration +bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail"; +upstream "local" { + default = yes; + self_scan = yes; +} diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index c248ef6..ce9a31c 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,3 +48,14 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 + +# Disable Validity network rules +dns_query_restriction deny sa-trusted.bondedsender.org +dns_query_restriction deny sa-accredit.habeas.com +dns_query_restriction deny bl.score.senderscore.com +score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 +score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 +score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 +score RCVD_IN_VALIDITY_CERTIFIED 0 +score RCVD_IN_VALIDITY_RPBL 0 +score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index c0c7002..74f1810 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -33,6 +33,16 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log user=mail group=mail +# rspamd spam filter +[program:rspamd] +command=/usr/bin/rspamd -f -u rspamd -g mail +autostart=true +autorestart=true +priority=11 +stdout_logfile=/var/log/happydeliver/rspamd.log +stderr_logfile=/var/log/happydeliver/rspamd_error.log +user=root + # SpamAssassin daemon [program:spamd] command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid diff --git a/generate.go b/generate.go index d1ee5ab..324c52c 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/go.mod b/go.mod index db5883d..9e7bee7 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,42 @@ module git.happydns.org/happyDeliver -go 1.24.6 +go 1.25.0 require ( + github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.132.0 - github.com/gin-gonic/gin v1.11.0 + github.com/getkin/kin-openapi v0.138.0 + github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 - github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.46.0 + github.com/oapi-codegen/runtime v1.3.0 + golang.org/x/net v0.54.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.31.0 + gorm.io/gorm v1.31.1 ) require ( - github.com/JGLTechnologies/gin-rate-limit v1.5.6 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -43,35 +44,37 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.1 // indirect - github.com/redis/go-redis/v9 v9.7.3 // indirect - github.com/speakeasy-api/jsonpath v0.6.0 // indirect - github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b378447..872377c 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,19 @@ -github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= -github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= +github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -16,8 +22,9 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn 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/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -30,35 +37,35 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= -github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= +github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= -github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -84,8 +91,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -108,12 +115,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -124,14 +131,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= -github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= -github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= +github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +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.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= +github.com/oasdiff/yaml3 v0.0.9/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= @@ -148,54 +155,67 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= -github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= -github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= -github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= 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= @@ -203,13 +223,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -225,21 +245,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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= @@ -252,8 +272,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -275,5 +295,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= -gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3b57747..de2d5df 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,14 +31,18 @@ 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 @@ -76,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) { ) // Return response - c.JSON(http.StatusCreated, TestResponse{ + c.JSON(http.StatusCreated, model.TestResponse{ Id: base32ID, Email: openapi_types.Email(email), - Status: TestResponseStatusPending, - Message: stringPtr("Send your test email to the given address"), + Status: model.TestResponseStatusPending, + Message: utils.PtrTo("Send your test email to the given address"), }) } @@ -90,10 +94,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -101,20 +105,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Check if a report exists for this test ID reportExists, err := h.storage.ReportExists(testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to check test status", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Determine status based on report existence - var apiStatus TestStatus + var apiStatus model.TestStatus if reportExists { - apiStatus = TestStatusAnalyzed + apiStatus = model.TestStatusAnalyzed } else { - apiStatus = TestStatusPending + apiStatus = model.TestStatusPending } // Generate test email address using Base32-encoded UUID @@ -124,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { h.config.Email.Domain, ) - c.JSON(http.StatusOK, Test{ + c.JSON(http.StatusOK, model.Test{ Id: id, Email: openapi_types.Email(email), Status: apiStatus, @@ -137,10 +141,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -148,16 +152,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -172,10 +176,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -183,16 +187,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -206,10 +210,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -218,16 +222,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -235,20 +239,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Re-analyze the email using the current analyzer reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "analysis_error", Message: "Failed to re-analyze email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Update the report in storage if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to update report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -264,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := StatusComponentsDatabaseUp + dbStatus := model.StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = StatusComponentsDatabaseDown + dbStatus = model.StatusComponentsDatabaseDown } // Determine overall status - overallStatus := Healthy - if dbStatus == StatusComponentsDatabaseDown { - overallStatus = Unhealthy + overallStatus := model.Healthy + if dbStatus == model.StatusComponentsDatabaseDown { + overallStatus = model.Unhealthy } - mtaStatus := StatusComponentsMtaUp - c.JSON(http.StatusOK, Status{ + mtaStatus := model.StatusComponentsMtaUp + c.JSON(http.StatusOK, model.Status{ Status: overallStatus, - Version: "0.1.0-dev", + Version: version.Version, Components: &struct { - Database *StatusComponentsDatabase `json:"database,omitempty"` - Mta *StatusComponentsMta `json:"mta,omitempty"` + Database *model.StatusComponentsDatabase `json:"database,omitempty"` + Mta *model.StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -289,3 +293,133 @@ func (h *APIHandler) GetStatus(c *gin.Context) { Uptime: &uptime, }) } + +// TestDomain performs synchronous domain analysis +// (POST /domain) +func (h *APIHandler) TestDomain(c *gin.Context) { + var request model.DomainTestRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Perform domain analysis + dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) + + // Convert grade string to DomainTestResponseGrade enum + var responseGrade model.DomainTestResponseGrade + switch grade { + case "A+": + responseGrade = model.DomainTestResponseGradeA + case "A": + responseGrade = model.DomainTestResponseGradeA1 + case "B": + responseGrade = model.DomainTestResponseGradeB + case "C": + responseGrade = model.DomainTestResponseGradeC + case "D": + responseGrade = model.DomainTestResponseGradeD + case "E": + responseGrade = model.DomainTestResponseGradeE + case "F": + responseGrade = model.DomainTestResponseGradeF + default: + responseGrade = model.DomainTestResponseGradeF + } + + // Build response + response := model.DomainTestResponse{ + Domain: request.Domain, + Score: score, + Grade: responseGrade, + DnsResults: *dnsResults, + } + + c.JSON(http.StatusOK, response) +} + +// CheckBlacklist checks an IP address against DNS blacklists +// (POST /blacklist) +func (h *APIHandler) CheckBlacklist(c *gin.Context) { + var request model.BlacklistCheckRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Perform blacklist check using analyzer + checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error{ + Error: "invalid_ip", + Message: "Invalid IP address", + Details: utils.PtrTo(err.Error()), + }) + return + } + + // Build response + response := model.BlacklistCheckResponse{ + Ip: request.Ip, + Blacklists: checks, + Whitelists: &whitelists, + ListedCount: listedCount, + Score: score, + Grade: model.BlacklistCheckResponseGrade(grade), + } + + c.JSON(http.StatusOK, response) +} + +// ListTests returns a paginated list of test summaries +// (GET /tests) +func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { + if h.config.DisableTestList { + c.JSON(http.StatusForbidden, model.Error{ + Error: "feature_disabled", + Message: "Test listing is disabled on this instance", + }) + return + } + + offset := 0 + limit := 20 + if params.Offset != nil { + offset = *params.Offset + } + if params.Limit != nil { + limit = *params.Limit + if limit > 100 { + limit = 100 + } + } + + tests, total, err := h.storage.ListReportSummaries(offset, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error{ + Error: "internal_error", + Message: "Failed to list tests", + Details: utils.PtrTo(err.Error()), + }) + return + } + + c.JSON(http.StatusOK, model.TestListResponse{ + Tests: tests, + Total: int(total), + Offset: offset, + Limit: limit, + }) +} diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 4334711..d8336a5 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -86,18 +86,549 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { // outputHumanReadable outputs a human-readable summary func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { - // Header + report := result.Report + + // Header with overall score fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") fmt.Fprintln(writer, strings.Repeat("=", 70)) + fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade) + fmt.Fprintf(writer, "Test ID: %s\n", report.TestId) + fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST")) - // Detailed checks - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "DETAILED CHECK RESULTS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) + // Score Summary + if report.Summary != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SCORE BREAKDOWN") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - // TODO + summary := report.Summary + fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", + summary.DnsScore, summary.DnsGrade) + fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", + summary.AuthenticationScore, summary.AuthenticationGrade) + fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", + summary.BlacklistScore, summary.BlacklistGrade) + fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", + summary.HeaderScore, summary.HeaderGrade) + fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", + summary.SpamScore, summary.SpamGrade) + fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", + summary.ContentScore, summary.ContentGrade) + } + // DNS Results + if report.DnsResults != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DNS CONFIGURATION") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + dns := report.DnsResults + fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) + if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { + fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) + } + + // MX Records + if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { + fmt.Fprintln(writer, "\n MX Records (From Domain):") + for _, mx := range *dns.FromMxRecords { + status := "✓" + if !mx.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) + if mx.Error != nil { + fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) + } + fmt.Fprintln(writer) + } + } + + // SPF Records + if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { + fmt.Fprintln(writer, "\n SPF Records:") + for _, spf := range *dns.SpfRecords { + status := "✓" + if !spf.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s ", status) + if spf.Domain != nil { + fmt.Fprintf(writer, "Domain: %s", *spf.Domain) + } + if spf.AllQualifier != nil { + fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) + } + fmt.Fprintln(writer) + if spf.Record != nil { + fmt.Fprintf(writer, " %s\n", *spf.Record) + } + if spf.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) + } + } + } + + // DKIM Records + if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { + fmt.Fprintln(writer, "\n DKIM Records:") + for _, dkim := range *dns.DkimRecords { + status := "✓" + if !dkim.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) + if dkim.Record != nil { + fmt.Fprintf(writer, " %s\n", *dkim.Record) + } + if dkim.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) + } + } + } + + // DMARC Record + if dns.DmarcRecord != nil { + fmt.Fprintln(writer, "\n DMARC Record:") + status := "✓" + if !dns.DmarcRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) + if dns.DmarcRecord.Policy != nil { + fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) + } + if dns.DmarcRecord.SubdomainPolicy != nil { + fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) + } + fmt.Fprintln(writer) + if dns.DmarcRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) + } + if dns.DmarcRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) + } + } + + // BIMI Record + if dns.BimiRecord != nil { + fmt.Fprintln(writer, "\n BIMI Record:") + status := "✓" + if !dns.BimiRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", + status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) + if dns.BimiRecord.LogoUrl != nil { + fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) + } + if dns.BimiRecord.VmcUrl != nil { + fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) + } + if dns.BimiRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) + } + if dns.BimiRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) + } + } + + // PTR Records + if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { + fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") + for _, ptr := range *dns.PtrRecords { + fmt.Fprintf(writer, " %s\n", ptr) + } + } + + // DNS Errors + if dns.Errors != nil && len(*dns.Errors) > 0 { + fmt.Fprintln(writer, "\n DNS Errors:") + for _, err := range *dns.Errors { + fmt.Fprintf(writer, " ! %s\n", err) + } + } + } + + // Authentication Results + if report.Authentication != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "AUTHENTICATION RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + auth := report.Authentication + + // SPF + if auth.Spf != nil { + fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) + if auth.Spf.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) + } + if auth.Spf.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) + } + fmt.Fprintln(writer) + } + + // DKIM + if auth.Dkim != nil && len(*auth.Dkim) > 0 { + fmt.Fprintln(writer, "\n DKIM:") + for i, dkim := range *auth.Dkim { + fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) + if dkim.Domain != nil { + fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) + if dkim.Selector != nil { + fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) + } + fmt.Fprintf(writer, ")") + } + if dkim.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) + } + fmt.Fprintln(writer) + } + } + + // DMARC + if auth.Dmarc != nil { + fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) + if auth.Dmarc.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) + } + if auth.Dmarc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) + } + fmt.Fprintln(writer) + } + + // ARC + if auth.Arc != nil { + fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) + if auth.Arc.ChainLength != nil { + fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) + } + if auth.Arc.ChainValid != nil { + fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) + } + if auth.Arc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) + } + fmt.Fprintln(writer) + } + + // BIMI + if auth.Bimi != nil { + fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) + if auth.Bimi.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) + } + if auth.Bimi.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) + } + fmt.Fprintln(writer) + } + + // IP Reverse + if auth.Iprev != nil { + fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) + if auth.Iprev.Ip != nil { + fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) + if auth.Iprev.Hostname != nil { + fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) + } + fmt.Fprintf(writer, ")") + } + if auth.Iprev.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) + } + fmt.Fprintln(writer) + } + } + + // Blacklist Results + if report.Blacklists != nil && len(*report.Blacklists) > 0 { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "BLACKLIST CHECKS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + totalChecks := 0 + totalListed := 0 + for ip, checks := range *report.Blacklists { + totalChecks += len(checks) + fmt.Fprintf(writer, "\n IP Address: %s\n", ip) + for _, check := range checks { + status := "✓" + if check.Listed { + status = "✗" + totalListed++ + } + fmt.Fprintf(writer, " %s %s", status, check.Rbl) + if check.Listed { + fmt.Fprintf(writer, " - LISTED") + if check.Response != nil { + fmt.Fprintf(writer, " (%s)", *check.Response) + } + } else { + fmt.Fprintf(writer, " - OK") + } + fmt.Fprintln(writer) + if check.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) + } + } + } + fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) + } + + // Header Analysis + if report.HeaderAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "HEADER ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + header := report.HeaderAnalysis + + // Domain Alignment + if header.DomainAlignment != nil { + fmt.Fprintln(writer, "\n Domain Alignment:") + align := header.DomainAlignment + if align.FromDomain != nil { + fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) + if align.FromOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) + } + fmt.Fprintln(writer) + } + if align.ReturnPathDomain != nil { + fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) + if align.ReturnPathOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) + } + fmt.Fprintln(writer) + } + if align.Aligned != nil { + fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) + } + if align.RelaxedAligned != nil { + fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) + } + if align.Issues != nil && len(*align.Issues) > 0 { + fmt.Fprintln(writer, " Issues:") + for _, issue := range *align.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + + // Required/Important Headers + if header.Headers != nil { + fmt.Fprintln(writer, "\n Standard Headers:") + importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} + for _, hdrName := range importantHeaders { + if hdr, ok := (*header.Headers)[hdrName]; ok { + status := "✗" + if hdr.Present { + status = "✓" + } + fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) + if hdr.Present { + if hdr.Valid != nil && !*hdr.Valid { + fmt.Fprintf(writer, "INVALID") + } else { + fmt.Fprintf(writer, "OK") + } + if hdr.Importance != nil { + fmt.Fprintf(writer, " [%s]", *hdr.Importance) + } + } else { + fmt.Fprintf(writer, "MISSING") + } + fmt.Fprintln(writer) + if hdr.Issues != nil && len(*hdr.Issues) > 0 { + for _, issue := range *hdr.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + } + } + + // Header Issues + if header.Issues != nil && len(*header.Issues) > 0 { + fmt.Fprintln(writer, "\n Header Issues:") + for _, issue := range *header.Issues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + + // Received Chain + if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { + fmt.Fprintln(writer, "\n Email Path (Received Chain):") + for i, hop := range *header.ReceivedChain { + fmt.Fprintf(writer, " [%d] ", i+1) + if hop.From != nil { + fmt.Fprintf(writer, "%s", *hop.From) + if hop.Ip != nil { + fmt.Fprintf(writer, " (%s)", *hop.Ip) + } + } + if hop.By != nil { + fmt.Fprintf(writer, " -> %s", *hop.By) + } + fmt.Fprintln(writer) + if hop.Timestamp != nil { + fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) + } + } + } + } + + // SpamAssassin Results + if report.Spamassassin != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + sa := report.Spamassassin + fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) + if sa.IsSpam { + fmt.Fprintf(writer, " (SPAM)") + } else { + fmt.Fprintf(writer, " (HAM)") + } + fmt.Fprintln(writer) + + if sa.Version != nil { + fmt.Fprintf(writer, " Version: %s\n", *sa.Version) + } + + if len(sa.TestDetails) > 0 { + fmt.Fprintln(writer, "\n Triggered Tests:") + for _, test := range sa.TestDetails { + scoreStr := "+" + if test.Score < 0 { + scoreStr = "" + } + fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) + if test.Description != nil { + fmt.Fprintf(writer, "\n %s", *test.Description) + } + fmt.Fprintln(writer) + } + } + } + + // Content Analysis + if report.ContentAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "CONTENT ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + content := report.ContentAnalysis + + // Basic content info + fmt.Fprintln(writer, "\n Content Structure:") + if content.HasPlaintext != nil { + fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) + } + if content.HasHtml != nil { + fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) + } + if content.TextToImageRatio != nil { + fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) + } + + // Unsubscribe + if content.HasUnsubscribeLink != nil { + fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) + if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { + fmt.Fprintf(writer, " Unsubscribe Methods: ") + for i, method := range *content.UnsubscribeMethods { + if i > 0 { + fmt.Fprintf(writer, ", ") + } + fmt.Fprintf(writer, "%s", method) + } + fmt.Fprintln(writer) + } + } + + // Links + if content.Links != nil && len(*content.Links) > 0 { + fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) + for _, link := range *content.Links { + status := "" + switch link.Status { + case "valid": + status = "✓" + case "broken": + status = "✗" + case "suspicious": + status = "⚠" + case "redirected": + status = "→" + case "timeout": + status = "⏱" + } + fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) + if link.HttpCode != nil { + fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) + } + fmt.Fprintln(writer) + if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { + fmt.Fprintln(writer, " Redirect chain:") + for _, url := range *link.RedirectChain { + fmt.Fprintf(writer, " -> %s\n", url) + } + } + } + } + + // Images + if content.Images != nil && len(*content.Images) > 0 { + fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) + missingAlt := 0 + trackingPixels := 0 + for _, img := range *content.Images { + if !img.HasAlt { + missingAlt++ + } + if img.IsTrackingPixel != nil && *img.IsTrackingPixel { + trackingPixels++ + } + } + fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", + len(*content.Images)-missingAlt, len(*content.Images)) + if trackingPixels > 0 { + fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) + } + } + + // HTML Issues + if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { + fmt.Fprintln(writer, "\n Content Issues:") + for _, issue := range *content.HtmlIssues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) + if issue.Location != nil { + fmt.Fprintf(writer, " Location: %s\n", *issue.Location) + } + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + } + + // Footer fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + return nil } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..4b01fbb --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,156 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + fmt.Fprintln(os.Stderr, "Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) + fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) + failed++ + continue + } + + if exists { + fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) + failed++ + continue + } + + restored++ + } + + fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go index fbb7a31..7149f45 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -78,26 +78,30 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - // Set up rate limiting (1 request per second per IP) - rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ - Rate: 4 * time.Second, - Limit: 2, - }) - 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() - }, - }) - - // Register API routes with rate limiting apiGroup := router.Group("/api") - apiGroup.Use(rateLimiter) + + if cfg.RateLimit > 0 { + // Set up rate limiting (2x to handle burst) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 2 * time.Second, + Limit: 2 * cfg.RateLimit, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + apiGroup.Use(rateLimiter) + } + + // Register API routes api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) diff --git a/internal/config/cli.go b/internal/config/cli.go index 93c18ce..fcc914f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,10 +34,17 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") + flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") + flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") + flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") + flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") + flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") + flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") + flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index 510aaa9..b264994 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "flag" "fmt" "log" + "net/url" "os" "path" "strings" @@ -33,6 +34,11 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +func getHostname() string { + h, _ := os.Hostname() + return h +} + // Config represents the application configuration type Config struct { DevProxy string @@ -41,6 +47,10 @@ type Config struct { Email EmailConfig Analysis AnalysisConfig ReportRetention time.Duration // How long to keep reports. 0 = keep forever + RateLimit uint // API rate limit (requests per second per IP) + SurveyURL url.URL // URL for user feedback survey + CustomLogoURL string // URL for custom logo image in the web UI + DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings @@ -54,13 +64,17 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string + ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis type AnalysisConfig struct { - DNSTimeout time.Duration - HTTPTimeout time.Duration - RBLs []string + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string + DNSWLs []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) } // DefaultConfig returns a configuration with sensible defaults @@ -69,6 +83,7 @@ func DefaultConfig() *Config { DevProxy: "", Bind: ":8080", ReportRetention: 0, // Keep reports forever by default + RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default) Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", @@ -77,11 +92,14 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", + ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, + DNSWLs: []string{}, + CheckAllIPs: false, // By default, only check the first IP }, } } diff --git a/internal/config/custom.go b/internal/config/custom.go index 9461632..97c8d71 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -23,6 +23,7 @@ package config import ( "fmt" + "net/url" "strings" ) @@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error { return nil } + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + *i.URL = *u + return nil +} diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 062a091..f06f535 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) + // Warn if the last Received hop doesn't match the expected receiver hostname + if r.config.Email.ReceiverHostname != "" && + result.Report.HeaderAnalysis != nil && + result.Report.HeaderAnalysis.ReceivedChain != nil && + len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { + lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] + if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { + log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) + } + } + // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 35aa0df..86605df 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,6 +30,9 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -45,6 +48,7 @@ type Storage interface { 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 @@ -139,6 +143,72 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } +// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary +type reportSummaryRow struct { + TestID uuid.UUID + Score int + Grade string + FromDomain string + CreatedAt time.Time +} + +// ListReportSummaries returns a paginated list of lightweight report summaries +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { + var total int64 + if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count reports: %w", err) + } + + if total == 0 { + return []model.TestSummary{}, 0, nil + } + + var selectExpr string + switch s.db.Dialector.Name() { + case "postgres": + selectExpr = `test_id, ` + + `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` + + `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + + `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + + `created_at` + case "sqlite": + selectExpr = `test_id, ` + + `json_extract(report_json, '$.score') as score, ` + + `json_extract(report_json, '$.grade') as grade, ` + + `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + + `created_at` + default: + return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") + } + + var rows []reportSummaryRow + err := s.db.Model(&Report{}). + Select(selectExpr). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Scan(&rows).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) + } + + summaries := make([]model.TestSummary, 0, len(rows)) + for _, r := range rows { + s := model.TestSummary{ + TestId: utils.UUIDToBase32(r.TestID), + Score: r.Score, + Grade: model.TestSummaryGrade(r.Grade), + CreatedAt: r.CreatedAt, + } + if r.FromDomain != "" { + s.FromDomain = utils.PtrTo(r.FromDomain) + } + summaries = append(summaries, s) + } + + return summaries, total, nil +} + // Close closes the database connection func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() @@ -147,3 +217,33 @@ func (s *DBStorage) Close() error { } return sqlDB.Close() } + +// GetAllReports retrieves all reports from the database +func GetAllReports(s Storage) ([]Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support GetAllReports") + } + + var reports []Report + if err := dbStorage.db.Find(&reports).Error; err != nil { + return nil, fmt.Errorf("failed to retrieve reports: %w", err) + } + + return reports, nil +} + +// CreateReportFromBackup creates a report from backup data, preserving timestamps +func CreateReportFromBackup(s Storage, report *Report) (*Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support CreateReportFromBackup") + } + + // Use Create to insert the report with all fields including timestamps + if err := dbStorage.db.Create(report).Error; err != nil { + return nil, fmt.Errorf("failed to create report from backup: %w", err) + } + + return report, nil +} diff --git a/internal/api/helpers.go b/internal/utils/ptr.go similarity index 91% rename from internal/api/helpers.go rename to internal/utils/ptr.go index cce306a..748d6ba 100644 --- a/internal/api/helpers.go +++ b/internal/utils/ptr.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain +// Copyright (c) 2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,11 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package api - -func stringPtr(s string) *string { - return &s -} +package utils // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a46c79f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,26 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package version + +// Version is the application version. It can be set at build time using ldflags: +// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3" +var Version = "(custom build)" diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 80fa7f2..5f57df3 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -28,7 +28,7 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/config" ) @@ -41,9 +41,13 @@ type EmailAnalyzer struct { // NewEmailAnalyzer creates a new email analyzer with the given configuration func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { generator := NewReportGenerator( + cfg.Email.ReceiverHostname, cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, + cfg.Analysis.CheckAllIPs, + cfg.Analysis.RspamdAPIURL, ) return &EmailAnalyzer{ @@ -55,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { type AnalysisResult struct { Email *EmailMessage Results *AnalysisResults - Report *api.Report + Report *model.Report } // AnalyzeEmailBytes performs complete email analysis from raw bytes @@ -107,3 +111,40 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt return reportJSON, nil } + +// AnalyzeDomain performs DNS analysis for a domain and returns the results +func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { + // Perform DNS analysis + dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) + + // Calculate score + score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults) + + return dnsResults, score, grade +} + +// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { + // Check the IP against all configured RBLs + checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) + if err != nil { + return nil, nil, 0, 0, "", err + } + + // Calculate score using the existing function + // Create a minimal RBLResults structure for scoring + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ip: checks}, + IPsChecked: []string{ip}, + ListedCount: listedCount, + } + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) + + // Check the IP against all configured DNSWLs (informational only) + whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) + if err != nil { + whitelists = nil + } + + return checks, whitelists, listedCount, score, grade, nil +} diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 02f8b28..da31b1c 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,23 +24,25 @@ package analyzer import ( "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct{} +type AuthenticationAnalyzer struct { + receiverHostname string +} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{} +func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{receiverHostname: receiverHostname} } // AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { - results := &api.AuthenticationResults{} +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { + results := &model.AuthenticationResults{} // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults() + authHeaders := email.GetAuthenticationResults(a.receiverHostname) for _, header := range authHeaders { a.parseAuthenticationResultsHeader(header, results) } @@ -50,13 +52,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results.Spf = a.parseLegacySPF(email) } - if results.Dkim == nil || len(*results.Dkim) == 0 { - dkimResults := a.parseLegacyDKIM(email) - if len(dkimResults) > 0 { - results.Dkim = &dkimResults - } - } - // Parse ARC headers if not already parsed from Authentication-Results if results.Arc == nil { results.Arc = a.parseARCHeaders(email) @@ -70,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api // parseAuthenticationResultsHeader parses an Authentication-Results header // Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { // Split by semicolon to get individual results parts := strings.Split(header, ";") if len(parts) < 2 { @@ -96,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, dkimResult := a.parseDKIMResult(part) if dkimResult != nil { if results.Dkim == nil { - dkimList := []api.AuthResult{*dkimResult} + dkimList := []model.AuthResult{*dkimResult} results.Dkim = &dkimList } else { *results.Dkim = append(*results.Dkim, *dkimResult) @@ -150,34 +145,39 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better -func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { if results == nil { return 0, "" } score := 0 - // IPRev (15 points) - score += 15 * a.calculateIPRevScore(results) / 100 + // Core authentication (90 points total) + // SPF (30 points) + score += 30 * a.calculateSPFScore(results) / 100 - // SPF (25 points) - score += 25 * a.calculateSPFScore(results) / 100 + // DKIM (30 points) + score += 30 * a.calculateDKIMScore(results) / 100 - // DKIM (23 points) - score += 23 * a.calculateDKIMScore(results) / 100 - - // X-Google-DKIM (optional) - penalty if failed - score += 12 * a.calculateXGoogleDKIMScore(results) / 100 - - // X-Aligned-From - score += 2 * a.calculateXAlignedFromScore(results) / 100 - - // DMARC (25 points) - score += 25 * a.calculateDMARCScore(results) / 100 + // DMARC (30 points) + score += 30 * a.calculateDMARCScore(results) / 100 // BIMI (10 points) score += 10 * a.calculateBIMIScore(results) / 100 + // Penalty-only: IPRev (up to -7 points on failure) + if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 { + score += 7 * (iprevScore - 100) / 100 + } + + // Penalty-only: X-Google-DKIM (up to -12 points on failure) + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 + + // Penalty-only: X-Aligned-From (up to -5 points on failure) + if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { + score += 5 * (xAlignedScore - 100) / 100 + } + // Ensure score doesn't exceed 100 if score > 100 { score = 100 diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go index 01b7505..e7333ce 100644 --- a/pkg/analyzer/authentication_arc.go +++ b/pkg/analyzer/authentication_arc.go @@ -27,7 +27,8 @@ import ( "slices" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // textprotoCanonical converts a header name to canonical form @@ -52,24 +53,24 @@ func pluralize(count int) string { // parseARCResult parses ARC result from Authentication-Results // Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} +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 = api.ARCResultResult(resultStr) + result.Result = model.ARCResultResult(resultStr) } - result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) + 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) *api.ARCResult { +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")] @@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe return nil } - result := &api.ARCResult{ - Result: api.ARCResultResultNone, + result := &model.ARCResult{ + Result: model.ARCResultResultNone, } // Count the ARC chain length (number of sets) @@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe // Determine overall result if chainLength == 0 { - result.Result = api.ARCResultResultNone + result.Result = model.ARCResultResultNone details := "No ARC chain present" result.Details = &details } else if !chainValid { - result.Result = api.ARCResultResultFail + result.Result = model.ARCResultResultFail details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) result.Details = &details } else { - result.Result = api.ARCResultResultPass + result.Result = model.ARCResultResultPass details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) result.Details = &details } @@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe } // enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { if arcResult == nil { return } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index 9269d70..ac51d0b 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -24,33 +24,33 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.ARCResultResult + expectedResult model.ARCResultResult }{ { name: "ARC pass", part: "arc=pass", - expectedResult: api.ARCResultResultPass, + expectedResult: model.ARCResultResultPass, }, { name: "ARC fail", part: "arc=fail", - expectedResult: api.ARCResultResultFail, + expectedResult: model.ARCResultResultFail, }, { name: "ARC none", part: "arc=none", - expectedResult: api.ARCResultResultNone, + expectedResult: model.ARCResultResultNone, }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go index 0d68281..9654ac7 100644 --- a/pkg/analyzer/authentication_bimi.go +++ b/pkg/analyzer/authentication_bimi.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) return result } -func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { if results.Bimi != nil { switch results.Bimi.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultDeclined: + case model.AuthResultResultDeclined: return 59 default: // fail return 0 diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index b1b5468..440f356 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -24,47 +24,47 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseBIMIResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + 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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI fail", part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultFail, + 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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "v1", }, { name: "BIMI none", part: "bimi=none header.d=example.com", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDomain: "example.com", }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go index 9ce0dd2..4165d8b 100644 --- a/pkg/analyzer/authentication_dkim.go +++ b/pkg/analyzer/authentication_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,56 +55,27 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) 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 -} - -func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { +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 == api.AuthResultResultPass { + if dkim.Result == model.AuthResultResultPass { hasPass = true - break + } else { + hasNonPass = true } } - if hasPass { + if hasPass && hasNonPass { + // Could be better + return 90 + } else if hasPass { return 100 } else { // Has DKIM signatures but none passed diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 323e421..0576854 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -22,44 +22,43 @@ package analyzer import ( - "strings" "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + 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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "DKIM fail", part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: api.AuthResultResultFail, + 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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -85,246 +84,3 @@ func TestParseDKIMResult(t *testing.T) { }) } } - -func TestParseLegacyDKIM(t *testing.T) { - tests := []struct { - name string - dkimSignatures []string - expectedCount int - expectedDomains []string - expectedSelector []string - }{ - { - name: "Single DKIM signature with domain and selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "Multiple DKIM signatures", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", - "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "example.com"}, - expectedSelector: []string{"selector1", "selector2"}, - }, - { - name: "DKIM signature with different domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", - }, - expectedCount: 1, - expectedDomains: []string{"mail.example.org"}, - expectedSelector: []string{"default"}, - }, - { - name: "DKIM signature with subdomain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", - }, - expectedCount: 1, - expectedDomains: []string{"newsletters.example.com"}, - expectedSelector: []string{"marketing"}, - }, - { - name: "Multiple signatures from different domains", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", - "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "relay.com"}, - expectedSelector: []string{"s1", "s2"}, - }, - { - name: "No DKIM signatures", - dkimSignatures: []string{}, - expectedCount: 0, - expectedDomains: []string{}, - expectedSelector: []string{}, - }, - { - name: "DKIM signature without selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{""}, - }, - { - name: "DKIM signature without domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; s=selector1; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{""}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with whitespace in parameters", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with multiline format", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with ed25519 algorithm", - dkimSignatures: []string{ - "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"ed25519"}, - }, - { - name: "Complex real-world DKIM signature", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", - }, - expectedCount: 1, - expectedDomains: []string{"google.com"}, - expectedSelector: []string{"20230601"}, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with DKIM-Signature headers - email := &EmailMessage{ - Header: make(map[string][]string), - } - if len(tt.dkimSignatures) > 0 { - email.Header["Dkim-Signature"] = tt.dkimSignatures - } - - results := analyzer.parseLegacyDKIM(email) - - // Check count - if len(results) != tt.expectedCount { - t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) - return - } - - // Check each result - for i, result := range results { - // All legacy DKIM results should have Result = none - if result.Result != api.AuthResultResultNone { - t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) - } - - // Check domain - if i < len(tt.expectedDomains) { - expectedDomain := tt.expectedDomains[i] - if expectedDomain != "" { - if result.Domain == nil { - t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) - } else if strings.TrimSpace(*result.Domain) != expectedDomain { - t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) - } - } - } - - // Check selector - if i < len(tt.expectedSelector) { - expectedSelector := tt.expectedSelector[i] - if expectedSelector != "" { - if result.Selector == nil { - t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) - } else if strings.TrimSpace(*result.Selector) != expectedSelector { - t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) - } - } - } - - // Check that Details is set - if result.Details == nil { - t.Errorf("Result[%d].Details = nil, expected non-nil", i) - } else { - expectedDetails := "DKIM signature present (verification status unknown)" - if *result.Details != expectedDetails { - t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) - } - } - } - }) - } -} - -func TestParseLegacyDKIM_Integration(t *testing.T) { - hostname = "" - - // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication - t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultNone { - t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { - t.Error("Expected domain to be 'example.com'") - } - }) - - t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - // Both Authentication-Results and DKIM-Signature headers - email.Header["Authentication-Results"] = []string{ - "mx.example.com; dkim=pass header.d=verified.com header.s=s1", - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { - t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { - t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") - } - }) -} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go index 329a5c9..c89093d 100644 --- a/pkg/analyzer/authentication_dmarc.go +++ b/pkg/analyzer/authentication_dmarc.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.from) @@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) return result } -func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { if results.Dmarc != nil { switch results.Dmarc.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNone: + case model.AuthResultResultNone: return 33 default: // fail return 0 diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index d7fda84..69779a7 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -24,31 +24,31 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDMARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "DMARC pass", part: "dmarc=pass action=none header.from=example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "DMARC fail", part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index 6538cbb..3ed045c 100644 --- a/pkg/analyzer/authentication_iprev.go +++ b/pkg/analyzer/authentication_iprev.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.IPRevResult { - result := &api.IPRevResult{} +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 = api.IPRevResultResult(resultStr) + result.Result = model.IPRevResultResult(resultStr) } // Extract IP address (smtp.remote-ip or remote-ip) @@ -54,20 +55,20 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult result.Hostname = &hostname } - result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) return result } -func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { if results.Iprev != nil { switch results.Iprev.Result { - case api.Pass: + case model.Pass: return 100 default: // fail, temperror, permerror return 0 } } - return 0 + return 100 } diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index d0529b5..55f85d5 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -24,76 +24,77 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseIPRevResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.IPRevResultResult + 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: api.Pass, - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("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: api.Pass, - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("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: api.Fail, - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: api.PtrTo("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: api.Temperror, - expectedIP: api.PtrTo("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: api.Permerror, - expectedIP: api.PtrTo("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: api.Pass, - expectedIP: api.PtrTo("2001:db8::1"), - expectedHostname: api.PtrTo("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: api.Pass, - expectedIP: api.PtrTo("192.0.2.50"), - expectedHostname: api.PtrTo("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: api.Pass, - expectedIP: api.PtrTo("192.0.2.200"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.200"), expectedHostname: nil, }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string header string - expectedIPRevResult *api.IPRevResultResult + 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: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("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: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.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: api.PtrTo(api.Fail), - expectedIP: api.PtrTo("198.51.100.42"), + expectedIPRevResult: utils.PtrTo(model.Fail), + expectedIP: utils.PtrTo("198.51.100.42"), expectedHostname: nil, }, { @@ -175,17 +176,17 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { { name: "Multiple IPRev results - only first is parsed", header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("first.com"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("first.com"), }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check IPRev diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go index 479c325..1488c98 100644 --- a/pkg/analyzer/authentication_spf.go +++ b/pkg/analyzer/authentication_spf.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain @@ -51,25 +52,35 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { } } - result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + 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) *api.AuthResult { +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult { receivedSPF := email.Header.Get("Received-SPF") if receivedSPF == "" { return nil } - result := &api.AuthResult{} + // 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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } result.Details = &receivedSPF @@ -87,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe return result } -func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { if results.Spf != nil { switch results.Spf.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNeutral, api.AuthResultResultNone: + case model.AuthResultResultNeutral, model.AuthResultResultNone: return 50 - case api.AuthResultResultSoftfail: + case model.AuthResultResultSoftfail: return 17 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 7a84c49..210505a 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -24,43 +24,44 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseSPFResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "SPF pass with domain", part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "SPF fail", part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "SPF neutral", part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDomain: "example.com", }, { name: "SPF softfail", part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultSoftfail, + expectedResult: model.AuthResultResultSoftfail, expectedDomain: "example.com", }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) { tests := []struct { name string receivedSPF string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain *string expectNil bool }{ @@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) { envelope-from="user@example.com"; helo=smtp.example.com; client-ip=192.0.2.10`, - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("example.com"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("example.com"), }, { name: "SPF fail with sender", @@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) { sender="sender@test.com"; helo=smtp.test.com; client-ip=192.0.2.20`, - expectedResult: api.AuthResultResultFail, - expectedDomain: api.PtrTo("test.com"), + 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: api.AuthResultResultSoftfail, - expectedDomain: api.PtrTo("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: api.AuthResultResultNeutral, - expectedDomain: api.PtrTo("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: api.AuthResultResultNone, - expectedDomain: api.PtrTo("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: api.AuthResultResultTemperror, - expectedDomain: api.PtrTo("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: api.AuthResultResultPermerror, - expectedDomain: api.PtrTo("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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: nil, }, { @@ -156,12 +157,12 @@ func TestParseLegacySPF(t *testing.T) { { name: "SPF with unquoted envelope-from", receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("mail.example.net"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("mail.example.net"), }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 27901b5..44c1abb 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -24,83 +24,84 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string - results *api.AuthenticationResults + results *model.AuthenticationResults expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, + Dmarc: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultFail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultFail, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultSoftfail, }, }, expectedScore: 4, }, { name: "No authentication", - results: &api.AuthenticationResults{}, + results: &model.AuthenticationResults{}, expectedScore: 0, }, { name: "BIMI adds to score", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, + Bimi: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 35, // SPF (25) + BIMI (10) }, } - scorer := NewAuthenticationAnalyzer() + scorer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string header string - expectedSPFResult *api.AuthResultResult + expectedSPFResult *model.AuthResultResult expectedSPFDomain *string expectedDKIMCount int - expectedDKIMResult *api.AuthResultResult - expectedDMARCResult *api.AuthResultResult + expectedDKIMResult *model.AuthResultResult + expectedDMARCResult *model.AuthResultResult expectedDMARCDomain *string - expectedBIMIResult *api.AuthResultResult - expectedARCResult *api.ARCResultResult + expectedBIMIResult *model.AuthResultResult + expectedARCResult *model.ARCResultResult }{ { name: "Complete authentication results", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { name: "SPF only", header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("domain.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("domain.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, @@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "Multiple DKIM signatures", header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", expectedSPFResult: nil, expectedDKIMCount: 2, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + 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: api.PtrTo(api.AuthResultResultFail), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultFail), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF softfail", header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail), - expectedSPFDomain: api.PtrTo("example.com"), + 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: api.PtrTo(api.AuthResultResultPass), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), - expectedDMARCDomain: api.PtrTo("example.com"), + 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: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "ARC pass", header: "mail.example.com; arc=pass", expectedSPFResult: nil, expectedDKIMCount: 0, - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + 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: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + 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)", @@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { { name: "Empty parts with semicolons", header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { @@ -230,28 +231,28 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass d=example.com s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "SPF neutral", header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { name: "SPF none", header: "mail.example.com; spf=none", - expectedSPFResult: api.PtrTo(api.AuthResultResultNone), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), expectedDKIMCount: 0, }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check SPF @@ -353,17 +354,17 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { // This test verifies that only the first occurrence of each auth method is parsed - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Spf == nil { t.Fatal("Expected SPF result, got nil") } - if results.Spf.Result != api.AuthResultResultPass { + 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" { @@ -373,13 +374,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dmarc == nil { t.Fatal("Expected DMARC result, got nil") } - if results.Dmarc.Result != api.AuthResultResultPass { + 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" { @@ -389,26 +390,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; arc=pass; arc=fail" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Arc == nil { t.Fatal("Expected ARC result, got nil") } - if results.Arc.Result != api.ARCResultResultPass { + 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 := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Bimi == nil { t.Fatal("Expected BIMI result, got nil") } - if results.Bimi.Result != api.AuthResultResultPass { + 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" { @@ -419,7 +420,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { // DKIM is special - multiple signatures should all be collected header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dkim == nil { @@ -428,10 +429,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { if len(*results.Dkim) != 2 { t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { + 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 != api.AuthResultResultFail { + if (*results.Dkim)[1].Result != model.AuthResultResultFail { t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) } }) diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index 36da2b0..ec1571c 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -25,34 +25,35 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract details (everything after the result) - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) return result } -func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { if results.XAlignedFrom != nil { switch results.XAlignedFrom.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: positive contribution return 100 - case api.AuthResultResultFail: + case model.AuthResultResultFail: // fail: negative contribution return 0 default: @@ -61,5 +62,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.Authent } } - return 0 + return 100 } diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 220ac39..1ea6d1c 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -24,49 +24,49 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXAlignedFromResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDetail string }{ { name: "x-aligned-from pass with details", part: "x-aligned-from=pass (Address match)", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass (Address match)", }, { name: "x-aligned-from fail with reason", part: "x-aligned-from=fail (Address mismatch)", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDetail: "fail (Address mismatch)", }, { name: "x-aligned-from pass minimal", part: "x-aligned-from=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass", }, { name: "x-aligned-from neutral", part: "x-aligned-from=neutral (No alignment check performed)", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDetail: "neutral (No alignment check performed)", }, { name: "x-aligned-from none", part: "x-aligned-from=none", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDetail: "none", }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) { func TestCalculateXAlignedFromScore(t *testing.T) { tests := []struct { name string - result *api.AuthResult + result *model.AuthResult expectedScore int }{ { name: "pass result gives positive score", - result: &api.AuthResult{ - Result: api.AuthResultResultPass, + result: &model.AuthResult{ + Result: model.AuthResultResultPass, }, expectedScore: 100, }, { name: "fail result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultFail, + result: &model.AuthResult{ + Result: model.AuthResultResultFail, }, expectedScore: 0, }, { name: "neutral result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNeutral, + result: &model.AuthResult{ + Result: model.AuthResultResultNeutral, }, expectedScore: 0, }, { name: "none result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNone, + result: &model.AuthResult{ + Result: model.AuthResultResultNone, }, expectedScore: 0, }, @@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{ + results := &model.AuthenticationResults{ XAlignedFrom: tt.result, } diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go index 4bba469..b33279e 100644 --- a/pkg/analyzer/authentication_x_google_dkim.go +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.AuthResult { - result := &api.AuthResult{} +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 = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { if results.XGoogleDkim != nil { switch results.XGoogleDkim.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: don't alter the score default: // fail return -100 diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index be29a08..4013340 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -24,43 +24,43 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXGoogleDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + 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: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "1e100.net", }, { name: "x-google-dkim pass with short form", part: "x-google-dkim=pass d=gmail.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "gmail.com", }, { name: "x-google-dkim fail", part: "x-google-dkim=fail header.d=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "x-google-dkim with minimal info", part: "x-google-dkim=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 613e5d5..06f8ddf 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -27,18 +27,22 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "time" "unicode" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" "golang.org/x/net/html" ) // ContentAnalyzer analyzes email content (HTML, links, images) type ContentAnalyzer struct { - Timeout time.Duration - httpClient *http.Client + Timeout time.Duration + httpClient *http.Client + listUnsubscribeURLs []string // URLs from List-Unsubscribe header + hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click } // NewContentAnalyzer creates a new content analyzer with configurable timeout @@ -110,6 +114,13 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *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() @@ -220,6 +231,18 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { // Validate link linkCheck := c.validateLink(href) + + // Check for domain misalignment (phishing detection) + linkText := c.getNodeText(n) + if c.hasDomainMisalignment(href, linkText) { + linkCheck.IsSafe = false + if linkCheck.Warning == "" { + linkCheck.Warning = "Link text domain does not match actual URL domain (possible phishing)" + } else { + linkCheck.Warning += "; Link text domain does not match actual URL domain (possible phishing)" + } + } + results.Links = append(results.Links, linkCheck) // Check for suspicious URLs @@ -319,9 +342,14 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string { // isUnsubscribeLink checks if a link is an unsubscribe link func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool { + // First check: does the href match a URL from the List-Unsubscribe header? + if slices.Contains(c.listUnsubscribeURLs, href) { + return true + } + // Check href for unsubscribe keywords lowerHref := strings.ToLower(href) - unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"} + unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"} for _, keyword := range unsubKeywords { if strings.Contains(lowerHref, keyword) { return true @@ -415,8 +443,131 @@ func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck { return check } +// hasDomainMisalignment checks if the link text contains a different domain than the actual URL +// This is a common phishing technique (e.g., text shows "paypal.com" but links to "evil.com") +func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool { + // Parse the actual URL + parsedURL, err := url.Parse(href) + if err != nil { + return false + } + + // Extract the actual destination domain/email based on scheme + var actualDomain string + + switch parsedURL.Scheme { + case "mailto": + // Extract email address from mailto: URL + // Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=... + mailtoAddr := parsedURL.Opaque + + // Remove query parameters if present + if idx := strings.Index(mailtoAddr, "?"); idx != -1 { + mailtoAddr = mailtoAddr[:idx] + } + + mailtoAddr = strings.TrimSpace(strings.ToLower(mailtoAddr)) + + // Extract domain from email address + if idx := strings.Index(mailtoAddr, "@"); idx != -1 { + actualDomain = mailtoAddr[idx+1:] + } else { + return false // Invalid mailto + } + case "http": + case "https": + // Check if URL has a host + if parsedURL.Host == "" { + return false + } + + // Extract the actual URL's domain (remove port if present) + actualDomain = parsedURL.Host + if idx := strings.LastIndex(actualDomain, ":"); idx != -1 { + actualDomain = actualDomain[:idx] + } + actualDomain = strings.ToLower(actualDomain) + default: + // Skip checks for other URL schemes (tel, etc.) + return false + } + + // Normalize link text + linkText = strings.TrimSpace(linkText) + linkText = strings.ToLower(linkText) + + // Skip if link text is empty, too short, or just generic text like "click here" + if linkText == "" || len(linkText) < 4 { + return false + } + + // Common generic link texts that shouldn't trigger warnings + genericTexts := []string{ + "click here", "read more", "learn more", "download", "subscribe", + "unsubscribe", "view online", "view in browser", "click", "here", + "update", "verify", "confirm", "continue", "get started", + // mailto-specific generic texts + "email us", "contact us", "send email", "get in touch", "reach out", + "contact", "email", "write to us", + } + if slices.Contains(genericTexts, linkText) { + return false + } + + // Extract domain-like patterns from link text using regex + // Matches patterns like "example.com", "www.example.com", "http://example.com" + domainRegex := regexp.MustCompile(`(?i)(?:https?://)?(?:www\.)?([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}`) + matches := domainRegex.FindAllString(linkText, -1) + + if len(matches) == 0 { + return false + } + + // Check each domain-like pattern found in the text + for _, textDomain := range matches { + // Normalize the text domain + textDomain = strings.ToLower(textDomain) + textDomain = strings.TrimPrefix(textDomain, "http://") + textDomain = strings.TrimPrefix(textDomain, "https://") + textDomain = strings.TrimPrefix(textDomain, "www.") + + // Remove trailing slashes and paths + if idx := strings.Index(textDomain, "/"); idx != -1 { + textDomain = textDomain[:idx] + } + + // Compare domains - they should match or the actual URL should be a subdomain of the text domain + if textDomain != actualDomain { + // Check if actual domain is a subdomain of text domain + if !strings.HasSuffix(actualDomain, "."+textDomain) && !strings.HasSuffix(actualDomain, textDomain) { + // Check if they share the same base domain (last 2 parts) + textParts := strings.Split(textDomain, ".") + actualParts := strings.Split(actualDomain, ".") + + if len(textParts) >= 2 && len(actualParts) >= 2 { + textBase := strings.Join(textParts[len(textParts)-2:], ".") + actualBase := strings.Join(actualParts[len(actualParts)-2:], ".") + + if textBase != actualBase { + return true // Domain mismatch detected! + } + } else { + return true // Domain mismatch detected! + } + } + } + } + + return false +} + // isSuspiciousURL checks if a URL looks suspicious func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool { + // Skip checks for mailto: URLs + if parsedURL.Scheme == "mailto" { + return false + } + // Check for IP address instead of domain if c.isIPAddress(parsedURL.Host) { return true @@ -427,10 +578,8 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo "bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co", "buff.ly", "is.gd", "bl.ink", "short.io", } - for _, shortener := range shorteners { - if strings.ToLower(parsedURL.Host) == shortener { - return true - } + if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) { + return true } // Check for excessive subdomains (possible obfuscation) @@ -492,7 +641,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { var extract func(*html.Node) extract = func(n *html.Node) { if n.Type == html.TextNode { - text.WriteString(n.Data) + text.WriteString(" " + n.Data) } // Skip script and style tags if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") { @@ -504,7 +653,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { } extract(doc) - return text.String() + return strings.TrimSpace(text.String()) } // calculateTextPlainConsistency compares plain text and HTML versions @@ -524,30 +673,47 @@ func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText stri return 0.0 } - // Count common words - commonWords := 0 - plainWordSet := make(map[string]bool) + // Count common words by building sets + plainWordSet := make(map[string]int) for _, word := range plainWords { - plainWordSet[word] = true + plainWordSet[word]++ } + htmlWordSet := make(map[string]int) for _, word := range htmlWords { - if plainWordSet[word] { - commonWords++ + htmlWordSet[word]++ + } + + // Count matches: for each unique word, count minimum occurrences in both texts + commonWords := 0 + for word, plainCount := range plainWordSet { + if htmlCount, exists := htmlWordSet[word]; exists { + // Count the minimum occurrences between both texts + if plainCount < htmlCount { + commonWords += plainCount + } else { + commonWords += htmlCount + } } } - // Calculate ratio (Jaccard similarity approximation) - maxWords := len(plainWords) - if len(htmlWords) > maxWords { - maxWords = len(htmlWords) - } - - if maxWords == 0 { + // Calculate ratio using total words from both texts (union approach) + // This provides a balanced measure: perfect match = 1.0, partial overlap = 0.3-0.8 + totalWords := len(plainWords) + len(htmlWords) + if totalWords == 0 { return 0.0 } - return float32(commonWords) / float32(maxWords) + // Divide by average word count for better scoring + avgWords := float32(totalWords) / 2.0 + ratio := float32(commonWords) / avgWords + + // Cap at 1.0 for perfect matches + if ratio > 1.0 { + ratio = 1.0 + } + + return ratio } // normalizeText normalizes text for comparison @@ -563,15 +729,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string { } // GenerateContentAnalysis creates structured content analysis from results -func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis { +func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis { if results == nil { return nil } - analysis := &api.ContentAnalysis{ - HasHtml: api.PtrTo(results.HTMLContent != ""), - HasPlaintext: api.PtrTo(results.TextContent != ""), - HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), + analysis := &model.ContentAnalysis{ + HasHtml: utils.PtrTo(results.HTMLContent != ""), + HasPlaintext: utils.PtrTo(results.TextContent != ""), + HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe), + UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{}, } // Calculate text-to-image ratio (inverse of image-to-text) @@ -584,16 +751,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } // Build HTML issues - htmlIssues := []api.ContentIssue{} + htmlIssues := []model.ContentIssue{} // Add HTML parsing errors if !results.HTMLValid && len(results.HTMLErrors) > 0 { for _, errMsg := range results.HTMLErrors { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.BrokenHtml, - Severity: api.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.BrokenHtml, + Severity: model.ContentIssueSeverityHigh, Message: errMsg, - Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"), + Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"), }) } } @@ -607,53 +774,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } } if missingAltCount > 0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.MissingAlt, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.MissingAlt, + Severity: model.ContentIssueSeverityMedium, Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount), - Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), + Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), }) } } // Add excessive images issue if results.ImageTextRatio > 10.0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.ExcessiveImages, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.ExcessiveImages, + Severity: model.ContentIssueSeverityMedium, Message: "Email is excessively image-heavy", - Advice: api.PtrTo("Reduce the number of images relative to text content"), + Advice: utils.PtrTo("Reduce the number of images relative to text content"), }) } // Add suspicious URL issues for _, suspURL := range results.SuspiciousURLs { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.SuspiciousLink, - Severity: api.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.SuspiciousLink, + Severity: model.ContentIssueSeverityHigh, Message: "Suspicious URL detected", Location: &suspURL, - Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), + Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), }) } // Add harmful HTML tag issues for _, harmfulIssue := range results.HarmfullIssues { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.DangerousHtml, - Severity: api.ContentIssueSeverityCritical, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.DangerousHtml, + Severity: model.ContentIssueSeverityCritical, Message: harmfulIssue, - Advice: api.PtrTo("Remove dangerous HTML tags like

More

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

Text

More

", - expectedText: "TextMore", + expectedText: "Text More", }, { name: "Empty HTML", @@ -144,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) { linkText: "Read more", expected: false, }, + // Multilingual keyword detection - URL path + { + name: "German abmelden in URL", + href: "https://example.com/abmelden?id=42", + linkText: "Click here", + expected: true, + }, + { + name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)", + href: "https://example.com/se-desabonner?id=42", + linkText: "Click here", + expected: false, + }, + // Multilingual keyword detection - link text + { + name: "German Abmelden in link text", + href: "https://example.com/manage?id=42&lang=de", + linkText: "Abmelden", + expected: true, + }, + { + name: "French Se désabonner in link text", + href: "https://example.com/manage?id=42&lang=fr", + linkText: "Se désabonner", + expected: true, + }, + { + name: "Russian Отписаться in link text", + href: "https://example.com/manage?id=42&lang=ru", + linkText: "Отписаться", + expected: true, + }, + { + name: "Chinese 退订 in link text", + href: "https://example.com/manage?id=42&lang=zh", + linkText: "退订", + expected: true, + }, + { + name: "Japanese 登録を取り消す in link text", + href: "https://example.com/manage?id=42&lang=ja", + linkText: "登録を取り消す", + expected: true, + }, + { + name: "Korean 구독 해지 in link text", + href: "https://example.com/manage?id=42&lang=ko", + linkText: "구독 해지", + expected: true, + }, + { + name: "Dutch Uitschrijven in link text", + href: "https://example.com/manage?id=42&lang=nl", + linkText: "Uitschrijven", + expected: true, + }, + { + name: "Polish Odsubskrybuj in link text", + href: "https://example.com/manage?id=42&lang=pl", + linkText: "Odsubskrybuj", + expected: true, + }, + { + name: "Turkish Üyeliği sonlandır in link text", + href: "https://example.com/manage?id=42&lang=tr", + linkText: "Üyeliği sonlandır", + expected: true, + }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -213,6 +281,16 @@ func TestIsSuspiciousURL(t *testing.T) { url: "https://mail.example.com/page", expected: false, }, + { + name: "Mailto with @ symbol", + url: "mailto:support@example.com", + expected: false, + }, + { + name: "Mailto with multiple @ symbols", + url: "mailto:user@subdomain@example.com", + expected: false, + }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -628,3 +706,276 @@ func findFirstLink(n *html.Node) *html.Node { func parseURL(urlStr string) (*url.URL, error) { return url.Parse(urlStr) } + +func TestHasDomainMisalignment(t *testing.T) { + tests := []struct { + name string + href string + linkText string + expected bool + reason string + }{ + // Phishing cases - should return true + { + name: "Obvious phishing - different domains", + href: "https://evil.com/page", + linkText: "Click here to verify your paypal.com account", + expected: true, + reason: "Link text shows 'paypal.com' but URL points to 'evil.com'", + }, + { + name: "Domain in link text differs from URL", + href: "http://attacker.net", + linkText: "Visit google.com for more info", + expected: true, + reason: "Link text shows 'google.com' but URL points to 'attacker.net'", + }, + { + name: "URL shown in text differs from actual URL", + href: "https://phishing-site.xyz/login", + linkText: "https://www.bank.example.com/secure", + expected: true, + reason: "Full URL in text doesn't match actual destination", + }, + { + name: "Similar but different domain", + href: "https://paypa1.com/login", + linkText: "Login to your paypal.com account", + expected: true, + reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'", + }, + { + name: "Subdomain spoofing", + href: "https://paypal.com.evil.com/login", + linkText: "Verify your paypal.com account", + expected: true, + reason: "Domain is 'evil.com', not 'paypal.com'", + }, + { + name: "Multiple domains in text, none match", + href: "https://badsite.com", + linkText: "Transfer from bank.com to paypal.com", + expected: true, + reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'", + }, + + // Legitimate cases - should return false + { + name: "Exact domain match", + href: "https://example.com/page", + linkText: "Visit example.com for more information", + expected: false, + reason: "Domains match exactly", + }, + { + name: "Legitimate subdomain", + href: "https://mail.google.com/inbox", + linkText: "Check your google.com email", + expected: false, + reason: "Subdomain of the mentioned domain", + }, + { + name: "www prefix variation", + href: "https://www.example.com/page", + linkText: "Visit example.com", + expected: false, + reason: "www prefix is acceptable variation", + }, + { + name: "Generic link text - click here", + href: "https://anywhere.com", + linkText: "click here", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - read more", + href: "https://example.com/article", + linkText: "Read more", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - learn more", + href: "https://example.com/info", + linkText: "Learn More", + expected: false, + reason: "Generic text doesn't contain a domain (case insensitive)", + }, + { + name: "No domain in link text", + href: "https://example.com/page", + linkText: "Click to continue", + expected: false, + reason: "Link text has no domain reference", + }, + { + name: "Short link text", + href: "https://example.com", + linkText: "Go", + expected: false, + reason: "Text too short to contain meaningful domain", + }, + { + name: "Empty link text", + href: "https://example.com", + linkText: "", + expected: false, + reason: "Empty text cannot contain domain", + }, + { + name: "Mailto link - matching domain", + href: "mailto:support@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Mailto email matches text email", + }, + { + name: "Mailto link - domain mismatch (phishing)", + href: "mailto:attacker@evil.com", + linkText: "Contact support@paypal.com for help", + expected: true, + reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'", + }, + { + name: "Mailto link - generic text", + href: "mailto:info@example.com", + linkText: "Contact us", + expected: false, + reason: "Generic text without domain reference", + }, + { + name: "Mailto link - same domain different user", + href: "mailto:sales@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Both emails share the same domain", + }, + { + name: "Mailto link - text shows only domain", + href: "mailto:info@example.com", + linkText: "Write to example.com", + expected: false, + reason: "Text domain matches mailto domain", + }, + { + name: "Mailto link - domain in text doesn't match", + href: "mailto:scam@phishing.net", + linkText: "Reply to customer-service@amazon.com", + expected: true, + reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text", + }, + { + name: "Tel link", + href: "tel:+1234567890", + linkText: "Call example.com support", + expected: false, + reason: "Non-HTTP(S) links are excluded", + }, + { + name: "Same base domain with different subdomains", + href: "https://www.example.com/page", + linkText: "Visit blog.example.com", + expected: false, + reason: "Both share same base domain 'example.com'", + }, + { + name: "URL with path matches domain in text", + href: "https://example.com/section/page", + linkText: "Go to example.com", + expected: false, + reason: "Domain matches, path doesn't matter", + }, + { + name: "Generic text - subscribe", + href: "https://newsletter.example.com/signup", + linkText: "Subscribe", + expected: false, + reason: "Generic call-to-action text", + }, + { + name: "Generic text - unsubscribe", + href: "https://example.com/unsubscribe?id=123", + linkText: "Unsubscribe", + expected: false, + reason: "Generic unsubscribe text", + }, + { + name: "Generic text - download", + href: "https://files.example.com/document.pdf", + linkText: "Download", + expected: false, + reason: "Generic action text", + }, + { + name: "Descriptive text without domain", + href: "https://shop.example.com/products", + linkText: "View our latest products", + expected: false, + reason: "No domain mentioned in text", + }, + + // Edge cases + { + name: "Domain-like text but not valid domain", + href: "https://example.com", + linkText: "Save up to 50.00 dollars", + expected: false, + reason: "50.00 looks like domain but isn't", + }, + { + name: "Text with http prefix matching domain", + href: "https://example.com/page", + linkText: "Visit http://example.com", + expected: false, + reason: "Domains match despite different protocols in display", + }, + { + name: "Port in URL should not affect matching", + href: "https://example.com:8080/page", + linkText: "Go to example.com", + expected: false, + reason: "Port number doesn't affect domain matching", + }, + { + name: "Whitespace in link text", + href: "https://example.com", + linkText: " example.com ", + expected: false, + reason: "Whitespace should be trimmed", + }, + { + name: "Multiple spaces in generic text", + href: "https://example.com", + linkText: "click here", + expected: false, + reason: "Generic text with extra spaces", + }, + { + name: "Anchor fragment in URL", + href: "https://example.com/page#section", + linkText: "example.com section", + expected: false, + reason: "Fragment doesn't affect domain matching", + }, + { + name: "Query parameters in URL", + href: "https://example.com/page?utm_source=email", + linkText: "Visit example.com", + expected: false, + reason: "Query params don't affect domain matching", + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.hasDomainMisalignment(tt.href, tt.linkText) + if result != tt.expected { + t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s", + tt.href, tt.linkText, result, tt.expected, tt.reason) + } + }) + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index c76359c..3210dd1 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -22,42 +22,48 @@ package analyzer import ( - "net" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // DNSAnalyzer analyzes DNS records for email domains type DNSAnalyzer struct { Timeout time.Duration - resolver *net.Resolver + resolver DNSResolver } // NewDNSAnalyzer creates a new DNS analyzer with configurable timeout func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { + return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver()) +} + +// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver. +// If resolver is nil, a StandardDNSResolver will be used. +func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer { if timeout == 0 { timeout = 10 * time.Second // Default timeout } + if resolver == nil { + resolver = NewStandardDNSResolver() + } return &DNSAnalyzer{ - Timeout: timeout, - resolver: &net.Resolver{ - PreferGo: true, - }, + Timeout: timeout, + resolver: resolver, } } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults { // Extract domain from From address if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { - return &api.DNSResults{ + return &model.DNSResults{ Errors: &[]string{"Unable to extract domain from email"}, } } fromDomain := *headersResults.DomainAlignment.FromDomain - results := &api.DNSResults{ + results := &model.DNSResults{ FromDomain: fromDomain, RpDomain: headersResults.DomainAlignment.ReturnPathDomain, } @@ -98,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) - // Check DKIM records (from authentication results) - // DKIM can be for any domain, but typically the From domain - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - if results.DkimRecords == nil { - results.DkimRecords = new([]api.DKIMRecord) - } - *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) - } + // Check DKIM records by parsing DKIM-Signature headers directly + for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { + dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) + if dkimRecord != nil { + if results.DkimRecords == nil { + results.DkimRecords = new([]model.DKIMRecord) } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } @@ -124,10 +125,74 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic return results } +// AnalyzeDomainOnly performs DNS validation for a domain without email context +// This is useful for checking domain configuration without sending an actual email +func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { + results := &model.DNSResults{ + FromDomain: domain, + } + + // Check MX records + results.FromMxRecords = d.checkMXRecords(domain) + + // Check SPF records + results.SpfRecords = d.checkSPFRecords(domain) + + // Check DMARC record + results.DmarcRecord = d.checkDMARCRecord(domain) + + // Check BIMI record with default selector + results.BimiRecord = d.checkBIMIRecord(domain, "default") + + return results +} + +// CalculateDomainOnlyScore calculates the DNS score for domain-only tests +// Returns a score from 0-100 where higher is better +// This version excludes PTR and DKIM checks since they require email context +func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) { + if results == nil { + return 0, "" + } + + score := 0 + + // MX Records: 30 points (only one domain to check) + mxScore := d.calculateMXScore(results) + // Since calculateMXScore checks both From and RP domains, + // and we only have From domain, we use the full score + score += 30 * mxScore / 100 + + // SPF Records: 30 points + score += 30 * d.calculateSPFScore(results) / 100 + + // DMARC Record: 40 points + score += 40 * d.calculateDMARCScore(results) / 100 + + // BIMI Record: only bonus + if results.BimiRecord != nil && results.BimiRecord.Valid { + if score >= 100 { + return 100, "A+" + } + } + + // Ensure score doesn't exceed maximum + if score > 100 { + score = 100 + } + + // Ensure score is non-negative + if score < 0 { + score = 0 + } + + return score, ScoreToGradeKind(score) +} + // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better // senderIP is the original sender IP address used for FCrDNS verification -func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) { +func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) { if results == nil { return 0, "" } diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go index 44240e9..223bfdc 100644 --- a/pkg/analyzer/dns_bimi.go +++ b/pkg/analyzer/dns_bimi.go @@ -27,11 +27,12 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *api.BIMIRecord { +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord { // BIMI records are at: selector._bimi.domain bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) @@ -40,20 +41,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) if err != nil { - return &api.BIMIRecord{ + return &model.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), } } if len(txtRecords) == 0 { - return &api.BIMIRecord{ + return &model.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: api.PtrTo("No BIMI record found"), + Error: utils.PtrTo("No BIMI record found"), } } @@ -66,18 +67,18 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) if !d.validateBIMI(bimiRecord) { - return &api.BIMIRecord{ + return &model.BIMIRecord{ Selector: selector, Domain: domain, Record: &bimiRecord, LogoUrl: &logoURL, VmcUrl: &vmcURL, Valid: false, - Error: api.PtrTo("BIMI record appears malformed"), + Error: utils.PtrTo("BIMI record appears malformed"), } } - return &api.BIMIRecord{ + return &model.BIMIRecord{ Selector: selector, Domain: domain, Record: &bimiRecord, diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 7ac858d..2ae03cb 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -26,11 +26,44 @@ import ( "fmt" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) -// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { +// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. +type DKIMHeader struct { + Domain string + Selector string +} + +// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. +func parseDKIMSignatures(signatures []string) []DKIMHeader { + var results []DKIMHeader + for _, sig := range signatures { + var domain, selector 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 + } + } + if domain != "" && selector != "" { + results = append(results, DKIMHeader{Domain: domain, Selector: selector}) + } + } + return results +} + +// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord { // DKIM records are at: selector._domainkey.domain dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) @@ -39,20 +72,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { - return &api.DKIMRecord{ + return &model.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), } } if len(txtRecords) == 0 { - return &api.DKIMRecord{ + return &model.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: api.PtrTo("No DKIM record found"), + Error: utils.PtrTo("No DKIM record found"), } } @@ -61,16 +94,16 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // Basic validation - should contain "v=DKIM1" and "p=" (public key) if !d.validateDKIM(dkimRecord) { - return &api.DKIMRecord{ + return &model.DKIMRecord{ Selector: selector, Domain: domain, - Record: api.PtrTo(dkimRecord), + Record: utils.PtrTo(dkimRecord), Valid: false, - Error: api.PtrTo("DKIM record appears malformed"), + Error: utils.PtrTo("DKIM record appears malformed"), } } - return &api.DKIMRecord{ + return &model.DKIMRecord{ Selector: selector, Domain: domain, Record: &dkimRecord, @@ -94,7 +127,7 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool { return true } -func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) { +func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { // DKIM provides strong email authentication if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { hasValidDKIM := false diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go index 8d94d20..45da53c 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -26,6 +26,220 @@ import ( "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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}, + {Domain: "sendib.com", Selector: "mail"}, + }, + }, + { + 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"}, + {Domain: "football.example.com", Selector: "test"}, + }, + }, + { + 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"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, + }, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}}, + }, + { + 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"}, + {Domain: "also-good.com", Selector: "sel2"}, + }, + }, + } + + 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) + } + } + }) + } +} + func TestValidateDKIM(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 3b73ecc..686fb0b 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -27,11 +27,12 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) -// checkapi.DMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { +// checkmodel.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { // DMARC records are at: _dmarc.domain dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) @@ -40,9 +41,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) if err != nil { - return &api.DMARCRecord{ + return &model.DMARCRecord{ Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } @@ -56,9 +57,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { } if dmarcRecord == "" { - return &api.DMARCRecord{ + return &model.DMARCRecord{ Valid: false, - Error: api.PtrTo("No DMARC record found"), + Error: utils.PtrTo("No DMARC record found"), } } @@ -77,21 +78,21 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { // Basic validation if !d.validateDMARC(dmarcRecord) { - return &api.DMARCRecord{ + return &model.DMARCRecord{ Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, SpfAlignment: spfAlignment, DkimAlignment: dkimAlignment, Valid: false, - Error: api.PtrTo("DMARC record appears malformed"), + Error: utils.PtrTo("DMARC record appears malformed"), } } - return &api.DMARCRecord{ + return &model.DMARCRecord{ Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, SpfAlignment: spfAlignment, @@ -113,44 +114,44 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { // extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record // Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment { // Look for aspf=s (strict) or aspf=r (relaxed) re := regexp.MustCompile(`aspf=(r|s)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) + return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) } - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) } // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) } // extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record // Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment { // Look for adkim=s (strict) or adkim=r (relaxed) re := regexp.MustCompile(`adkim=(r|s)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) + return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) } - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) } // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) } // extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record // Returns the sp tag value or nil if not specified (defaults to main policy) -func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy { // Look for sp=none, sp=quarantine, or sp=reject re := regexp.MustCompile(`sp=(none|quarantine|reject)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { - return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) + return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1])) } // If sp is not specified, it defaults to the main policy (p tag) // Return nil to indicate it's using the default @@ -191,7 +192,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { return true } -func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) { +func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { // DMARC ties SPF and DKIM together and provides policy if results.DmarcRecord != nil { if results.DmarcRecord.Valid { @@ -210,10 +211,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) { } } // Bonus points for strict alignment modes (2 points each) - if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict { score += 5 } - if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict { score += 5 } // Subdomain policy scoring (sp tag) diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 0868e48..93f4511 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -25,7 +25,8 @@ import ( "testing" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestExtractDMARCPolicy(t *testing.T) { @@ -228,17 +229,17 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { { name: "Subdomain policy - none", record: "v=DMARC1; p=quarantine; sp=none", - expectedPolicy: api.PtrTo("none"), + expectedPolicy: utils.PtrTo("none"), }, { name: "Subdomain policy - quarantine", record: "v=DMARC1; p=reject; sp=quarantine", - expectedPolicy: api.PtrTo("quarantine"), + expectedPolicy: utils.PtrTo("quarantine"), }, { name: "Subdomain policy - reject", record: "v=DMARC1; p=quarantine; sp=reject", - expectedPolicy: api.PtrTo("reject"), + expectedPolicy: utils.PtrTo("reject"), }, { name: "No subdomain policy specified (defaults to main policy)", @@ -248,7 +249,7 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { { name: "Complex record with subdomain policy", record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: api.PtrTo("quarantine"), + expectedPolicy: utils.PtrTo("quarantine"), }, } @@ -282,22 +283,22 @@ func TestExtractDMARCPercentage(t *testing.T) { { name: "Percentage - 100", record: "v=DMARC1; p=quarantine; pct=100", - expectedPercentage: api.PtrTo(100), + expectedPercentage: utils.PtrTo(100), }, { name: "Percentage - 50", record: "v=DMARC1; p=quarantine; pct=50", - expectedPercentage: api.PtrTo(50), + expectedPercentage: utils.PtrTo(50), }, { name: "Percentage - 25", record: "v=DMARC1; p=reject; pct=25", - expectedPercentage: api.PtrTo(25), + expectedPercentage: utils.PtrTo(25), }, { name: "Percentage - 0", record: "v=DMARC1; p=none; pct=0", - expectedPercentage: api.PtrTo(0), + expectedPercentage: utils.PtrTo(0), }, { name: "No percentage specified (defaults to 100)", @@ -307,7 +308,7 @@ func TestExtractDMARCPercentage(t *testing.T) { { name: "Complex record with percentage", record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", - expectedPercentage: api.PtrTo(75), + expectedPercentage: utils.PtrTo(75), }, { name: "Invalid percentage > 100 (ignored)", diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go index f90e5dc..07e5ab9 100644 --- a/pkg/analyzer/dns_fcr.go +++ b/pkg/analyzer/dns_fcr.go @@ -24,7 +24,7 @@ package analyzer import ( "context" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) @@ -63,7 +63,7 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { } // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability -func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) { +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 diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go index 68e55b5..c48c9a4 100644 --- a/pkg/analyzer/dns_mx.go +++ b/pkg/analyzer/dns_mx.go @@ -25,36 +25,37 @@ import ( "context" "fmt" - "git.happydns.org/happyDeliver/internal/api" + "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) *[]api.MXRecord { +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 &[]api.MXRecord{ + return &[]model.MXRecord{ { Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), }, } } if len(mxRecords) == 0 { - return &[]api.MXRecord{ + return &[]model.MXRecord{ { Valid: false, - Error: api.PtrTo("No MX records found"), + Error: utils.PtrTo("No MX records found"), }, } } - var results []api.MXRecord + var results []model.MXRecord for _, mx := range mxRecords { - results = append(results, api.MXRecord{ + results = append(results, model.MXRecord{ Host: mx.Host, Priority: mx.Pref, Valid: true, @@ -64,7 +65,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { return &results } -func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) { +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 { diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go new file mode 100644 index 0000000..f60484f --- /dev/null +++ b/pkg/analyzer/dns_resolver.go @@ -0,0 +1,80 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" +) + +// DNSResolver defines the interface for DNS resolution operations. +// This interface abstracts DNS lookups to allow for custom implementations, +// such as mock resolvers for testing or caching resolvers for performance. +type DNSResolver interface { + // LookupMX returns the DNS MX records for the given domain. + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + + // LookupTXT returns the DNS TXT records for the given domain. + LookupTXT(ctx context.Context, name string) ([]string, error) + + // LookupAddr performs a reverse lookup for the given IP address, + // returning a list of hostnames mapping to that address. + LookupAddr(ctx context.Context, addr string) ([]string, error) + + // LookupHost looks up the given hostname using the local resolver. + // It returns a slice of that host's addresses (IPv4 and IPv6). + LookupHost(ctx context.Context, host string) ([]string, error) +} + +// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver. +type StandardDNSResolver struct { + resolver *net.Resolver +} + +// NewStandardDNSResolver creates a new StandardDNSResolver with default settings. +func NewStandardDNSResolver() DNSResolver { + return &StandardDNSResolver{ + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// LookupMX implements DNSResolver.LookupMX using net.Resolver. +func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + return r.resolver.LookupMX(ctx, name) +} + +// LookupTXT implements DNSResolver.LookupTXT using net.Resolver. +func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + return r.resolver.LookupTXT(ctx, name) +} + +// LookupAddr implements DNSResolver.LookupAddr using net.Resolver. +func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + return r.resolver.LookupAddr(ctx, addr) +} + +// LookupHost implements DNSResolver.LookupHost using net.Resolver. +func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.resolver.LookupHost(ctx, host) +} diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go index bc7a1be..ccb1674 100644 --- a/pkg/analyzer/dns_spf.go +++ b/pkg/analyzer/dns_spf.go @@ -27,32 +27,34 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "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) *[]api.SPFRecord { +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord { visited := make(map[string]bool) - return d.resolveSPFRecords(domain, visited, 0) + return d.resolveSPFRecords(domain, visited, 0, true) } // resolveSPFRecords recursively resolves SPF records including include: directives -func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { +// 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 &[]api.SPFRecord{ + return &[]model.SPFRecord{ { Domain: &domain, Valid: false, - Error: api.PtrTo("Maximum SPF include depth exceeded"), + Error: utils.PtrTo("Maximum SPF include depth exceeded"), }, } } // Prevent circular references if visited[domain] { - return &[]api.SPFRecord{} + return &[]model.SPFRecord{} } visited[domain] = true @@ -61,11 +63,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { - return &[]api.SPFRecord{ + return &[]model.SPFRecord{ { Domain: &domain, Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), }, } } @@ -81,56 +83,56 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, } if spfCount == 0 { - return &[]api.SPFRecord{ + return &[]model.SPFRecord{ { Domain: &domain, Valid: false, - Error: api.PtrTo("No SPF record found"), + Error: utils.PtrTo("No SPF record found"), }, } } - var results []api.SPFRecord + var results []model.SPFRecord if spfCount > 1 { - results = append(results, api.SPFRecord{ + results = append(results, model.SPFRecord{ Domain: &domain, Record: &spfRecord, Valid: false, - Error: api.PtrTo("Multiple SPF records found (RFC violation)"), + Error: utils.PtrTo("Multiple SPF records found (RFC violation)"), }) return &results } // Basic validation - valid := d.validateSPF(spfRecord) + validationErr := d.validateSPF(spfRecord, isMainRecord) // Extract the "all" mechanism qualifier - var allQualifier *api.SPFRecordAllQualifier + var allQualifier *model.SPFRecordAllQualifier var errMsg *string - if !valid { - errMsg = api.PtrTo("SPF record appears malformed") + if validationErr != nil { + errMsg = utils.PtrTo(validationErr.Error()) } else { // Extract qualifier from the "all" mechanism if strings.HasSuffix(spfRecord, " -all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-")) } else if strings.HasSuffix(spfRecord, " ~all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~")) } else if strings.HasSuffix(spfRecord, " +all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+")) } else if strings.HasSuffix(spfRecord, " ?all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?")) } else if strings.HasSuffix(spfRecord, " all") { // Implicit + qualifier (default) - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+")) } } - results = append(results, api.SPFRecord{ + results = append(results, model.SPFRecord{ Domain: &domain, Record: &spfRecord, - Valid: valid, + Valid: validationErr == nil, AllQualifier: allQualifier, Error: errMsg, }) @@ -140,7 +142,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, 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) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false) if redirectRecords != nil { results = append(results, *redirectRecords...) } @@ -150,7 +152,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, // Extract and resolve include: directives includes := d.extractSPFIncludes(spfRecord) for _, includeDomain := range includes { - includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false) if includedRecords != nil { results = append(results, *includedRecords...) } @@ -183,30 +185,116 @@ func (d *DNSAnalyzer) extractSPFRedirect(record string) string { return "" } -// 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 +// 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 for redirect= modifier (which replaces the need for an 'all' mechanism) - if strings.Contains(record, "redirect=") { - return true + // Check standalone mechanisms (no domain/value required) + if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" { + return nil } - // 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 + // 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 hasValidEnding + 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 @@ -214,7 +302,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { return strings.HasSuffix(record, " -all") } -func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { +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 @@ -242,6 +330,12 @@ func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { // 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 { @@ -249,10 +343,16 @@ func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { // Strict fail - no deduction, this is the recommended policy score += 25 case "~": - // Softfail - moderate penalty + // 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 - score -= 25 + if !dmarcStrict { + score -= 25 + } } } else { // No 'all' mechanism qualifier extracted - severe penalty diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go index 132f063..2e794ce 100644 --- a/pkg/analyzer/dns_spf_test.go +++ b/pkg/analyzer/dns_spf_test.go @@ -22,60 +22,130 @@ package analyzer import ( + "strings" "testing" "time" ) func TestValidateSPF(t *testing.T) { tests := []struct { - name string - record string - expected bool + 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", - expected: true, + 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", - expected: true, + 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", - expected: true, + name: "Valid SPF with +all", + record: "v=spf1 +all", + expectError: false, }, { - name: "Valid SPF with ?all", - record: "v=spf1 mx ?all", - expected: true, + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expectError: false, }, { - name: "Valid SPF with redirect", - record: "v=spf1 redirect=_spf.example.com", - expected: true, + 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", - expected: true, + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expectError: false, }, { - name: "Invalid SPF - no version", - record: "include:_spf.example.com -all", - expected: 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: "Invalid SPF - no all mechanism or redirect", - record: "v=spf1 include:_spf.example.com", - expected: false, + name: "Valid SPF with exp modifier", + record: "v=spf1 mx exp=explain.example.com -all", + expectError: false, }, { - name: "Invalid SPF - wrong version", - record: "v=spf2 include:_spf.example.com -all", - expected: 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, }, } @@ -83,9 +153,86 @@ func TestValidateSPF(t *testing.T) { 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) + // 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) + } } }) } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 7e65571..f750742 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -31,7 +31,8 @@ import ( "golang.org/x/net/publicsuffix" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // HeaderAnalyzer analyzes email header quality and structure @@ -43,7 +44,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer { } // CalculateHeaderScore evaluates email structural quality from header analysis -func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) { +func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) { if analysis == nil || analysis.Headers == nil { return 0, ' ' } @@ -52,13 +53,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade := 6 headers := *analysis.Headers - // RP and From alignment (20 points) - if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { - score += 20 - } else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned { - score += 15 - } else { + // 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 @@ -79,7 +81,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade = 1 } - // Check recommended headers (20 points) + // Check recommended headers (15 points) recommendedHeaders := []string{"subject", "to"} // Add reply-to when from is a no-reply address @@ -95,7 +97,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int presentRecommended++ } } - score += presentRecommended * 20 / recommendedCount + score += presentRecommended * 15 / recommendedCount if presentRecommended < recommendedCount { maxGrade -= 1 @@ -108,6 +110,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int 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 @@ -179,7 +188,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) { } // isNoReplyAddress checks if a header check represents a no-reply email address -func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool { +func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool { if !headerCheck.Present || headerCheck.Value == nil { return false } @@ -235,18 +244,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { } // GenerateHeaderAnalysis creates structured header analysis from email -func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis { if email == nil { return nil } - analysis := &api.HeaderAnalysis{} + analysis := &model.HeaderAnalysis{} // Check for proper MIME structure - analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0) + analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0) // Initialize headers map - headers := make(map[string]api.HeaderCheck) + headers := make(map[string]model.HeaderCheck) // Check required headers requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"} @@ -265,6 +274,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header 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 { @@ -281,7 +294,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header } // Domain alignment - domainAlignment := h.analyzeDomainAlignment(email) + domainAlignment := h.analyzeDomainAlignment(email, authResults) if domainAlignment != nil { analysis.DomainAlignment = domainAlignment } @@ -296,12 +309,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header } // checkHeader checks if a header is present and valid -func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck { +func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck { value := email.GetHeaderValue(headerName) present := email.HasHeader(headerName) && value != "" - importanceEnum := api.HeaderCheckImportance(importance) - check := &api.HeaderCheck{ + importanceEnum := model.HeaderCheckImportance(importance) + check := &model.HeaderCheck{ Present: present, Importance: &importanceEnum, } @@ -319,12 +332,21 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp 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 { @@ -352,11 +374,11 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp return check } -// analyzeDomainAlignment checks domain alignment between headers -func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { - alignment := &api.DomainAlignment{ - Aligned: api.PtrTo(true), - RelaxedAligned: api.PtrTo(true), +// 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 @@ -383,14 +405,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain } } + // 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 := h.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{} - if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { + + // 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) - strictAligned := strings.EqualFold(fromDomain, rpDomain) + rpStrictAligned = strings.EqualFold(fromDomain, rpDomain) // Relaxed alignment: organizational domain match var fromOrgDomain, rpOrgDomain string @@ -400,20 +453,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain if alignment.ReturnPathOrgDomain != nil { rpOrgDomain = *alignment.ReturnPathOrgDomain } - relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) + rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain) - *alignment.Aligned = strictAligned - *alignment.RelaxedAligned = relaxedAligned - - if !strictAligned { - if relaxedAligned { + 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 } @@ -461,18 +561,18 @@ func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { } // findHeaderIssues identifies issues with headers -func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { - var issues []api.HeaderIssue +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, api.HeaderIssue{ + issues = append(issues, model.HeaderIssue{ Header: header, - Severity: api.HeaderIssueSeverityCritical, + Severity: model.HeaderIssueSeverityCritical, Message: fmt.Sprintf("Required header '%s' is missing", header), - Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)), + Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)), }) } } @@ -480,11 +580,11 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue // Check Message-ID format messageID := email.GetHeaderValue("Message-ID") if messageID != "" && !h.isValidMessageID(messageID) { - issues = append(issues, api.HeaderIssue{ + issues = append(issues, model.HeaderIssue{ Header: "Message-ID", - Severity: api.HeaderIssueSeverityMedium, + Severity: model.HeaderIssueSeverityMedium, Message: "Message-ID format is invalid", - Advice: api.PtrTo("Use proper Message-ID format: "), + Advice: utils.PtrTo("Use proper Message-ID format: "), }) } @@ -492,7 +592,7 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue } // parseReceivedChain extracts the chain of Received headers from an email -func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop { +func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop { if email == nil || email.Header == nil { return nil } @@ -502,7 +602,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH return nil } - var chain []api.ReceivedHop + var chain []model.ReceivedHop for _, receivedValue := range receivedHeaders { hop := h.parseReceivedHeader(receivedValue) @@ -515,8 +615,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH } // parseReceivedHeader parses a single Received header value -func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop { - hop := &api.ReceivedHop{} +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), " ") diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 7896a5c..d7469d7 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -24,9 +24,10 @@ package analyzer import ( "net/mail" "net/textproto" + "strings" "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestCalculateHeaderScore(t *testing.T) { @@ -82,8 +83,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 40, - maxScore: 80, + minScore: 80, + maxScore: 90, }, { name: "Invalid Message-ID format", @@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Generate header analysis first - analysis := analyzer.GenerateHeaderAnalysis(tt.email) + 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) @@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) { }), } - alignment := analyzer.analyzeDomainAlignment(email) + alignment := analyzer.analyzeDomainAlignment(email, nil) if alignment == nil { t.Fatal("Expected non-nil alignment") @@ -403,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) { name string receivedHeaders []string expectedHops int - validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop) + validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop) }{ { name: "No Received headers", @@ -416,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) { "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", }, expectedHops: 1, - validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { if len(hops) == 0 { t.Fatal("Expected at least one hop") } @@ -449,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) { "from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000", }, expectedHops: 2, - validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { if len(hops) != 2 { t.Fatalf("Expected 2 hops, got %d", len(hops)) } @@ -471,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) { "from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", }, expectedHops: 1, - validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { if len(hops) == 0 { t.Fatal("Expected at least one hop") } @@ -498,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) { for ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`, }, expectedHops: 1, - validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { if len(hops) == 0 { t.Fatal("Expected at least one hop") } @@ -526,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) { "from unknown by localhost", }, expectedHops: 1, - validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) { if len(hops) == 0 { t.Fatal("Expected at least one hop") } @@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { "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) + analysis := analyzer.GenerateHeaderAnalysis(email, nil) if analysis == nil { t.Fatal("GenerateHeaderAnalysis returned nil") @@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool { } 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 ca3cb46..00de151 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,16 +28,9 @@ import ( "mime/multipart" "net/mail" "net/textproto" - "os" "strings" ) -var hostname = "" - -func init() { - hostname, _ = os.Hostname() -} - // EmailMessage represents a parsed email message type EmailMessage struct { Header mail.Header @@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -// If hostname is provided, only returns headers that begin with that hostname -func (e *EmailMessage) GetAuthenticationResults() []string { +// 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 hostname == "" { + if receiverHostname == "" { return allResults } // Filter results that begin with the specified hostname var filtered []string - prefix := hostname + ";" + prefix := receiverHostname + ";" for _, result := range allResults { // Trim whitespace and check if it starts with hostname; trimmed := strings.TrimSpace(result) @@ -256,6 +249,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string { } for _, headerName := range saHeaders { + if values, ok := e.Header[headerName]; ok && len(values) > 0 { + for _, value := range values { + if strings.TrimSpace(value) != "" { + headers[headerName] = value + break + } + } + } else if value := e.Header.Get(headerName); value != "" { + headers[headerName] = value + } + } + + return headers +} + +// GetRspamdHeaders extracts rspamd-related headers +func (e *EmailMessage) GetRspamdHeaders() map[string]string { + headers := make(map[string]string) + + rspamdHeaders := []string{ + "X-Spamd-Result", + "X-Rspamd-Score", + "X-Rspamd-Action", + "X-Rspamd-Server", + } + + for _, headerName := range rspamdHeaders { if value := e.Header.Get(headerName); value != "" { headers[headerName] = value } @@ -301,3 +321,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string { func (e *EmailMessage) HasHeader(key string) bool { return e.Header.Get(key) != "" } + +// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs. +// The header format is: , , ... +func (e *EmailMessage) GetListUnsubscribeURLs() []string { + value := e.Header.Get("List-Unsubscribe") + if value == "" { + return nil + } + var urls []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") { + urls = append(urls, part[1:len(part)-1]) + } + } + return urls +} diff --git a/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go index 571f542..196e8e2 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -120,7 +120,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults() + authResults := email.GetAuthenticationResults("example.com") if len(authResults) != 2 { t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 832c61c..7dea559 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -27,16 +27,22 @@ import ( "net" "regexp" "strings" + "sync" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) -// RBLChecker checks IP addresses against DNS-based blacklists -type RBLChecker struct { - Timeout time.Duration - RBLs []string - resolver *net.Resolver +// DNSListChecker checks IP addresses against DNS-based block/allow lists. +// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags. +type DNSListChecker struct { + Timeout time.Duration + Lists []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors + resolver *net.Resolver + informationalSet map[string]bool // Lists whose hits don't count toward the score } // DefaultRBLs is a list of commonly used RBL providers @@ -47,39 +53,83 @@ var DefaultRBLs = []string{ "b.barracudacentral.org", // Barracuda "cbl.abuseat.org", // CBL (Composite Blocking List) "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational) + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational) + "psbl.surriel.com", // PSBL + "dnsbl.dronebl.org", // DroneBL + "bl.mailspike.net", // Mailspike BL + "z.mailspike.net", // Mailspike Z + "bl.rbl-dns.com", // RBL-DNS + "bl.nszones.com", // NSZones +} + +// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score. +// These are typically broader lists where being listed is less definitive. +var DefaultInformationalRBLs = []string{ + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring +} + +// DefaultDNSWLs is a list of commonly used DNSWL providers +var DefaultDNSWLs = []string{ + "list.dnswl.org", // DNSWL.org — the main DNS whitelist + "swl.spamhaus.org", // Spamhaus Safe Whitelist } // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list -func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { +func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker { if timeout == 0 { - timeout = 5 * time.Second // Default timeout + timeout = 5 * time.Second } if len(rbls) == 0 { rbls = DefaultRBLs } - return &RBLChecker{ - Timeout: timeout, - RBLs: rbls, - resolver: &net.Resolver{ - PreferGo: true, - }, + informationalSet := make(map[string]bool, len(DefaultInformationalRBLs)) + for _, rbl := range DefaultInformationalRBLs { + informationalSet[rbl] = true + } + return &DNSListChecker{ + Timeout: timeout, + Lists: rbls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: true, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: informationalSet, } } -// RBLResults represents the results of RBL checks -type RBLResults struct { - Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP - IPsChecked []string - ListedCount int +// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list +func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker { + if timeout == 0 { + timeout = 5 * time.Second + } + if len(dnswls) == 0 { + dnswls = DefaultDNSWLs + } + return &DNSListChecker{ + Timeout: timeout, + Lists: dnswls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: false, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: make(map[string]bool), + } } -// CheckEmail checks all IPs found in the email headers against RBLs -func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{ - Checks: make(map[string][]api.BlacklistCheck), +// DNSListResults represents the results of DNS list checks +type DNSListResults struct { + Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP + IPsChecked []string + ListedCount int // Total listings including informational entries + RelevantListedCount int // Listings on scoring (non-informational) lists only +} + +// CheckEmail checks all IPs found in the email headers against the configured lists +func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults { + results := &DNSListResults{ + Checks: make(map[string][]model.BlacklistCheck), } - // Extract IPs from Received headers ips := r.extractIPs(email) if len(ips) == 0 { return results @@ -87,42 +137,68 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results.IPsChecked = ips - // Check each IP against all RBLs for _, ip := range ips { - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) + for _, list := range r.Lists { + check := r.checkIP(ip, list) results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ + if !r.informationalSet[list] { + results.RelevantListedCount++ + } } } + + if !r.CheckAllIPs { + break + } } return results } +// CheckIP checks a single IP address against all configured lists in parallel +func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) { + if !r.isPublicIP(ip) { + return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) + } + + checks := make([]model.BlacklistCheck, len(r.Lists)) + var wg sync.WaitGroup + + for i, list := range r.Lists { + wg.Add(1) + go func(i int, list string) { + defer wg.Done() + checks[i] = r.checkIP(ip, list) + }(i, list) + } + wg.Wait() + + listedCount := 0 + for _, check := range checks { + if check.Listed { + listedCount++ + } + } + + return checks, listedCount, nil +} + // extractIPs extracts IP addresses from Received headers -func (r *RBLChecker) extractIPs(email *EmailMessage) []string { +func (r *DNSListChecker) extractIPs(email *EmailMessage) []string { var ips []string seenIPs := make(map[string]bool) - // Get all Received headers receivedHeaders := email.Header["Received"] - - // Regex patterns for IP addresses - // Match IPv4: xxx.xxx.xxx.xxx ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) - // Look for IPs in Received headers for _, received := range receivedHeaders { - // Find all IPv4 addresses matches := ipv4Pattern.FindAllString(received, -1) for _, match := range matches { - // Skip private/reserved IPs if !r.isPublicIP(match) { continue } - // Avoid duplicates if !seenIPs[match] { ips = append(ips, match) seenIPs[match] = true @@ -130,13 +206,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } } - // If no IPs found in Received headers, try X-Originating-IP if len(ips) == 0 { originatingIP := email.Header.Get("X-Originating-IP") if originatingIP != "" { - // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1" cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") - // Remove any whitespace cleanIP = strings.TrimSpace(cleanIP) matches := ipv4Pattern.FindString(cleanIP) if matches != "" && r.isPublicIP(matches) { @@ -149,19 +222,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } // isPublicIP checks if an IP address is public (not private, loopback, or reserved) -func (r *RBLChecker) isPublicIP(ipStr string) bool { +func (r *DNSListChecker) isPublicIP(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } - // Check if it's a private network if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } - // Additional checks for reserved ranges - // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) if ip.IsUnspecified() { return false } @@ -169,51 +239,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { return true } -// checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { - check := api.BlacklistCheck{ - Rbl: rbl, +// checkIP checks a single IP against a single DNS list +func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck { + check := model.BlacklistCheck{ + Rbl: list, } - // Reverse the IP for DNSBL query reversedIP := r.reverseIP(ip) if reversedIP == "" { - check.Error = api.PtrTo("Failed to reverse IP address") + check.Error = utils.PtrTo("Failed to reverse IP address") return check } - // Construct DNSBL query: reversed-ip.rbl-domain - query := fmt.Sprintf("%s.%s", reversedIP, rbl) + query := fmt.Sprintf("%s.%s", reversedIP, list) - // Perform DNS lookup with timeout ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) defer cancel() addrs, err := r.resolver.LookupHost(ctx, query) if err != nil { - // Most likely not listed (NXDOMAIN) if dnsErr, ok := err.(*net.DNSError); ok { if dnsErr.IsNotFound { check.Listed = false return check } } - // Other DNS errors - check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) + check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) return check } - // If we got a response, check the return code if len(addrs) > 0 { - check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2) + check.Response = utils.PtrTo(addrs[0]) - // Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 - // These indicate RBL operational issues, not actual listings - if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" { + // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings. + if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") { check.Listed = false - check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])) + check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0])) } else { - // Normal listing response check.Listed = true } } @@ -221,44 +283,58 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { return check } -// reverseIP reverses an IPv4 address for DNSBL queries +// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries // Example: 192.0.2.1 -> 1.2.0.192 -func (r *RBLChecker) reverseIP(ipStr string) string { +func (r *DNSListChecker) reverseIP(ipStr string) string { ip := net.ParseIP(ipStr) if ip == nil { return "" } - // Convert to IPv4 ipv4 := ip.To4() if ipv4 == nil { return "" // IPv6 not supported yet } - // Reverse the octets return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// CalculateRBLScore calculates the blacklist contribution to deliverability -func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { +// 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) + + if forWhitelist { + if results.ListedCount >= scoringListCount { + return 100, "A++" + } else if results.ListedCount > 0 { + return 100, "A+" + } else { + return 95, "A" + } + } + if results == nil || len(results.IPsChecked) == 0 { - // No IPs to check, give benefit of doubt return 100, "" } - percentage := 100 - results.ListedCount*100/len(r.RBLs) + if results.ListedCount <= 0 { + return 100, "A+" + } + + percentage := 100 - results.RelevantListedCount*100/scoringListCount return percentage, ScoreToGrade(percentage) } -// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL -func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry +func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string { var listedIPs []string - for ip, rblChecks := range results.Checks { - for _, check := range rblChecks { + for ip, checks := range results.Checks { + for _, check := range checks { if check.Listed { listedIPs = append(listedIPs, ip) - break // Only add the IP once + break } } } @@ -266,17 +342,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { return listedIPs } -// GetRBLsForIP returns all RBLs that list a specific IP -func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { - var rbls []string +// GetListsForIP returns all lists that match a specific IP +func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string { + var lists []string - if rblChecks, exists := results.Checks[ip]; exists { - for _, check := range rblChecks { + if checks, exists := results.Checks[ip]; exists { + for _, check := range checks { if check.Listed { - rbls = append(rbls, check.Rbl) + lists = append(lists, check.Rbl) } } } - return rbls + return lists } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index f18464a..8620038 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -26,7 +26,7 @@ import ( "testing" "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestNewRBLChecker(t *testing.T) { @@ -55,12 +55,12 @@ func TestNewRBLChecker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := NewRBLChecker(tt.timeout, tt.rbls) + checker := NewRBLChecker(tt.timeout, tt.rbls, false) if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.RBLs) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) + if len(checker.Lists) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -97,7 +97,7 @@ func TestReverseIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -157,7 +157,7 @@ func TestIsPublicIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -237,7 +237,7 @@ func TestExtractIPs(t *testing.T) { },*/ } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults + results *DNSListResults expectedScore int }{ { @@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, @@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 1 RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, @@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 2 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, @@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 3 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, @@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, @@ -322,11 +322,11 @@ func TestGetBlacklistScore(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateRBLScore(tt.results) + score, _ := checker.CalculateScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -335,8 +335,8 @@ func TestGetBlacklistScore(t *testing.T) { } func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ - Checks: map[string][]api.BlacklistCheck{ + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, {Rbl: "bl.spamcop.net", Listed: true}, @@ -351,7 +351,7 @@ func TestGetUniqueListedIPs(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) listedIPs := checker.GetUniqueListedIPs(results) expectedIPs := []string{"198.51.100.1", "198.51.100.2"} @@ -363,8 +363,8 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ - Checks: map[string][]api.BlacklistCheck{ + results := &DNSListResults{ + Checks: map[string][]model.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, {Rbl: "bl.spamcop.net", Listed: true}, @@ -376,7 +376,7 @@ func TestGetRBLsForIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) tests := []struct { name string @@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbls := checker.GetRBLsForIP(results, tt.ip) + rbls := checker.GetListsForIP(results, tt.ip) if len(rbls) != len(tt.expectedRBLs) { t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index bd6b866..26cd59d 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -24,7 +24,7 @@ package analyzer import ( "time" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) @@ -33,23 +33,31 @@ import ( type ReportGenerator struct { authAnalyzer *AuthenticationAnalyzer spamAnalyzer *SpamAssassinAnalyzer + rspamdAnalyzer *RspamdAnalyzer dnsAnalyzer *DNSAnalyzer - rblChecker *RBLChecker + rblChecker *DNSListChecker + dnswlChecker *DNSListChecker contentAnalyzer *ContentAnalyzer headerAnalyzer *HeaderAnalyzer } // NewReportGenerator creates a new report generator func NewReportGenerator( + receiverHostname string, dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, + dnswls []string, + checkAllIPs bool, + rspamdAPIURL string, ) *ReportGenerator { return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(), + authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), spamAnalyzer: NewSpamAssassinAnalyzer(), + rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), - rblChecker: NewRBLChecker(dnsTimeout, rbls), + rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), + dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), contentAnalyzer: NewContentAnalyzer(httpTimeout), headerAnalyzer: NewHeaderAnalyzer(), } @@ -58,12 +66,14 @@ func NewReportGenerator( // AnalysisResults contains all intermediate analysis results type AnalysisResults struct { Email *EmailMessage - Authentication *api.AuthenticationResults + Authentication *model.AuthenticationResults Content *ContentResults - DNS *api.DNSResults - Headers *api.HeaderAnalysis - RBL *RBLResults - SpamAssassin *api.SpamAssassinResult + DNS *model.DNSResults + Headers *model.HeaderAnalysis + RBL *DNSListResults + DNSWL *DNSListResults + SpamAssassin *model.SpamAssassinResult + Rspamd *model.RspamdResult } // AnalyzeEmail performs complete email analysis @@ -74,21 +84,23 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) + results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) + results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) return results } // GenerateReport creates a complete API report from analysis results -func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report { +func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report { reportID := uuid.New() now := time.Now() - report := &api.Report{ + report := &model.Report{ Id: utils.UUIDToBase32(reportID), TestId: utils.UUIDToBase32(testID), CreatedAt: now, @@ -129,29 +141,47 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu blacklistScore := 0 var blacklistGrade string + var whitelistGrade string if results.RBL != nil { - blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL) + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false) + _, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true) } - spamScore := 0 + 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 - if results.SpamAssassin != nil { - spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) + switch { + case saGrade == "" && rspamdGrade == "": + spamScore = 0 + spamGrade = "" + case saGrade == "": + spamScore = rspamdScore + spamGrade = rspamdGrade + case rspamdGrade == "": + spamScore = saScore + spamGrade = saGrade + default: + spamScore = (saScore + rspamdScore) / 2 + spamGrade = MinGrade(saGrade, rspamdGrade) } - report.Summary = &api.ScoreSummary{ + report.Summary = &model.ScoreSummary{ DnsScore: dnsScore, - DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade), + DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade), AuthenticationScore: authScore, - AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade), + AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade), BlacklistScore: blacklistScore, - BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade), + BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)), ContentScore: contentScore, - ContentGrade: api.ScoreSummaryContentGrade(contentGrade), + ContentGrade: model.ScoreSummaryContentGrade(contentGrade), HeaderScore: headerScore, - HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade), + HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade), SpamScore: spamScore, - SpamGrade: api.ScoreSummarySpamGrade(spamGrade), + SpamGrade: model.ScoreSummarySpamGrade(spamGrade), } // Add authentication results @@ -176,9 +206,27 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report.Blacklists = &results.RBL.Checks } - // Add SpamAssassin result + // 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 @@ -240,7 +288,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } if minusGrade < 255 { - report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade})) + report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade})) } } diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index bf413ce..5914737 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -32,7 +32,7 @@ import ( ) func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") if gen == nil { t.Fatal("Expected report generator, got nil") } @@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) { } func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") email := createTestEmail() @@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) { } func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") testID := uuid.New() email := createTestEmail() @@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) { } func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") testID := uuid.New() email := createTestEmailWithSpamAssassin() @@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") tests := []struct { name string diff --git a/pkg/analyzer/rspamd-symbols-README.md b/pkg/analyzer/rspamd-symbols-README.md new file mode 100644 index 0000000..882eab2 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols-README.md @@ -0,0 +1,21 @@ +# rspamd-symbols.json + +This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured. + +## How to update + +Fetch the latest symbols from a running rspamd instance: + +```sh +curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Or with docker: + +```sh +docker run --rm --name rspamd --pull always rspamd/rspamd +docker exec -u 0 rspamd apt install -y curl +docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Then rebuild the project. diff --git a/pkg/analyzer/rspamd-symbols.json b/pkg/analyzer/rspamd-symbols.json new file mode 100644 index 0000000..5538985 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols.json @@ -0,0 +1,6646 @@ +[ + { + "group": "arc", + "rules": [ + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rbl", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from SenderScore RPBL" + }, + { + "symbol": "RBL_VIRUSFREE_BOTNET", + "weight": 2.0, + "description": "From address is listed in virusfree.cz BL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_CRACKED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "SURBL_HASHBL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_MALWARE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_PHISH", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_EMAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_ABUSE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dnswl", + "rules": [ + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dmarc", + "rules": [ + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "statistics", + "rules": [ + { + "symbol": "BAYES_SPAM", + "weight": 5.100000, + "description": "Message probably spam, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAYES_HAM", + "weight": -3.0, + "description": "Message probably ham, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dkim", + "rules": [ + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "sem", + "rules": [ + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "neural", + "rules": [] + }, + { + "group": "policies", + "rules": [ + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surbl", + "rules": [ + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime", + "rules": [ + { + "symbol": "MIME_BASE64_TEXT_BOGUS", + "weight": 1.0, + "description": "Has text part encoded in base64 that does not contain any 8bit characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MIXED_BOGUS", + "weight": 1.0, + "description": "multipart/mixed without non-textual part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MISSING_DISPOSITION", + "weight": 4.0, + "description": "Binary content-type not specified as an attachment", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BASE64_TEXT", + "weight": 0.100000, + "description": "Has text part encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "multimap", + "rules": [ + { + "symbol": "DISPOSABLE_FROM", + "weight": 0.0, + "description": "From a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_TO", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_REPLYTO", + "weight": 0.0, + "description": "Reply-To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_CC", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_TO", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_MDN", + "weight": 0.500000, + "description": "Disposition-Notification-To is a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_MDN", + "weight": 0.0, + "description": "Disposition-Notification-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_FROM", + "weight": 0.0, + "description": "From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO", + "weight": 0.0, + "description": "Reply-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_CC", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL", + "weight": 0.0, + "description": "The presence of a redirector in the mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessqp", + "rules": [ + { + "symbol": "CC_EXCESS_QP", + "weight": 1.200000, + "description": "Cc header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_QP", + "weight": 1.200000, + "description": "Subject header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_QP", + "weight": 1.200000, + "description": "Reply-To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EXCESS_QP", + "weight": 1.200000, + "description": "From header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_QP", + "weight": 1.200000, + "description": "To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "upstream_spam_filters", + "rules": [ + { + "symbol": "UNITEDINTERNET_SPAM", + "weight": 5.0, + "description": "United Internet says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KLMS_SPAM", + "weight": 5.0, + "description": "Kaspersky Security for Mail Server says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MICROSOFT_SPAM", + "weight": 4.0, + "description": "Microsoft says the message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PRECEDENCE_BULK", + "weight": 0.0, + "description": "Message marked as bulk", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAM_FLAG", + "weight": 5.0, + "description": "Message was already marked as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers", + "rules": [ + { + "symbol": "FAKE_RECEIVED_smtp_yandex_ru", + "weight": 4.0, + "description": "Fake smtp.yandex.ru Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_RCONFIRM_MISMATCH", + "weight": 2.0, + "description": "Read confirmation address is different to from address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ZERO", + "weight": 0.0, + "description": "No recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILER_1C_8", + "weight": 0.0, + "description": "Sent with 1C:Enterprise 8", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPTO_QUOTE_YAHOO", + "weight": 2.0, + "description": "Quoted Reply-To header from Yahoo (seems to be forged)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_SEVEN", + "weight": 0.0, + "description": "Message has 7-11 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ZERO", + "weight": 0.0, + "description": "Message has no Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_DISPLAY_NAME", + "weight": 8.0, + "description": "Display name is being used to spoof and trick the recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_ALL", + "weight": 0.0, + "description": "All of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_FROM", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_EXCLAIM", + "weight": 0.0, + "description": "Subject ends with an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_IMS", + "weight": 3.0, + "description": "Forged X-Mailer: Internet Mail Service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER", + "weight": 0.300000, + "description": "Sender is forged (different From: header and smtp MAIL FROM: addresses)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ONE", + "weight": 0.0, + "description": "Message has one Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_RCPT_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in recipients headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_BOUN", + "weight": 2.0, + "description": "Forged The Bat! MUA headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAIL_RU_MAILER", + "weight": 0.0, + "description": "Sent with Mail.Ru webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_CC_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Cc header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OLD_X_MAILER", + "weight": 2.0, + "description": "X-Mailer header has a very old MUA version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED4", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_REPLY", + "weight": 1.0, + "description": "Fake reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STRONGMAIL", + "weight": 6.0, + "description": "Sent via rogue \"strongmail\" MTA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_FIVE", + "weight": 0.0, + "description": "Message has X-Priority header set to 5 or higher", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIME_VERSION", + "weight": 2.0, + "description": "MIME-Version header is missing in MIME message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RCVD", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DOUBLE_IP_SPAM", + "weight": 2.0, + "description": "Has two Received headers containing bare IP addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_REPLYTO", + "weight": 0.0, + "description": "Has Reply-To header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_HTML", + "weight": 1.0, + "description": "MIME multipart/alternative missing text/html part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DN_EQ_FROM_DN", + "weight": 0.0, + "description": "Reply-To display name matches From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHPOS_FAKE", + "weight": 3.0, + "description": "Fake X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_VERP", + "weight": 0.0, + "description": "Envelope From is a VERP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EQ_ENVFROM", + "weight": 0.0, + "description": "From address is the same as the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ORG_HEADER", + "weight": 0.0, + "description": "Has Organization header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_TO", + "weight": 2.0, + "description": "To header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS", + "weight": 10.0, + "description": "Headers structure is likely broken", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DN_EQ_ADDR", + "weight": 1.0, + "description": "From header display name is the same as the address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM_DOM", + "weight": 3.0, + "description": "The From and Reply-To addresses in the email are from different freemail services", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_HELO_LOCALHOST", + "weight": 0.0, + "description": "Localhost HELO seen in Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_BAD_CTE_7BIT", + "weight": 3.500000, + "description": "Detects bad Content-Transfer-Encoding for text parts", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FROM_EMPTY_DELIMITER", + "weight": 1.0, + "description": "From header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_QUESTION", + "weight": 0.0, + "description": "Subject contains a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ZERO", + "weight": 0.0, + "description": "Message has X-Priority header set to 0", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED", + "weight": 0.100000, + "description": "One received header in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INFO_TO_INFO_LU", + "weight": 2.0, + "description": "info@ From/To address with List-Unsubscribe headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_TEXT", + "weight": 2.0, + "description": "MIME multipart/alternative missing text/plain part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWO", + "weight": 0.0, + "description": "Two recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_THREE", + "weight": 0.0, + "description": "3-5 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO", + "weight": 0.0, + "description": "X-Priority check callback rule", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_NONE", + "weight": 0.0, + "description": "None of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWO", + "weight": 0.0, + "description": "Message has two Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTE_CASE", + "weight": 0.500000, + "description": "[78]Bit .vs. [78]bit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_EXCLAIM", + "weight": 0.0, + "description": "Subject contains an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_XM_UA", + "weight": 0.0, + "description": "Message has neither X-Mailer nor User-Agent header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_FORGED_0X", + "weight": 4.0, + "description": "X-PHP-Originating-Script header appears forged", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER", + "weight": 0.0, + "description": "Sent with Apple iPhone/iPad Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_LIST_UNSUB", + "weight": -0.010000, + "description": "Has List-Unsubscribe header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_INVALID", + "weight": 2.0, + "description": "Envelope from does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED3", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET", + "weight": 5.0, + "description": "Mixed characters in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID", + "weight": 1.700000, + "description": "Message-ID header is incorrect", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_SPACES", + "weight": 0.500000, + "description": "Subject ends with space characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWELVE", + "weight": 0.0, + "description": "Message has 12 or more Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_DISPLAY_NAME", + "weight": 4.0, + "description": "Display name contains an email address different to the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_CONTENT_TYPE", + "weight": 1.500000, + "description": "Message has part with broken content type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_DATE", + "weight": 1.0, + "description": "Date header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MSGID_YAHOO", + "weight": 2.0, + "description": "Forged Yahoo Message-ID header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_RCVD_SPAMBOTS", + "weight": 3.0, + "description": "Spambots signatures in received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MISSING_CHARSET", + "weight": 0.500000, + "description": "Charset header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID", + "weight": 2.500000, + "description": "Message-ID header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FORGED_MDN", + "weight": 2.0, + "description": "Read confirmation address is different to return path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_REPLYTO", + "weight": 6.0, + "description": "Reply-To is being used to spoof and trick the recipient to send an off-domain reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_DATE_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Date header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_SOME", + "weight": 0.0, + "description": "Some of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_FROM", + "weight": 2.0, + "description": "Missing From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_SEVEN", + "weight": 0.0, + "description": "7-11 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_UNPARSEABLE", + "weight": 1.0, + "description": "Reply-To header could not be parsed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ONE", + "weight": 0.0, + "description": "Message has X-Priority header set to 1", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_GT_50", + "weight": 0.0, + "description": "50+ recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_LAST", + "weight": 0.0, + "description": "Last hop used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_HAS_TITLE", + "weight": 1.0, + "description": "From header display name has a title (Mr/Mrs/Dr)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PREVIOUSLY_DELIVERED", + "weight": 0.0, + "description": "Message either to a list or was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_HELO_USER", + "weight": 3.0, + "description": "HELO User spam pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_X_MAILER", + "weight": 4.500000, + "description": "Forged X-Mailer header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_HTTP_URL_IN_FROM", + "weight": 5.0, + "description": "HTTP URL preceded by the start of a line, quote, or whitespace, with normal or URL-encoded colons in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "To domain is the same as the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWELVE", + "weight": 0.0, + "description": "12-50 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_TAGS", + "weight": 2.100000, + "description": "Message pretends to be send from Outlook but has 'strange' tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NO_DN", + "weight": 0.0, + "description": "From header does not have a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_DATE", + "weight": 1.500000, + "description": "Malformed Date header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_NO_SPACE_IN_FROM", + "weight": 1.0, + "description": "No space in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_HTML", + "weight": 5.0, + "description": "Forged Outlook HTML signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DISPLAY_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_ADDR_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to SMTP From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_WRAPPED_IN_SPACES", + "weight": 2.0, + "description": "To address is wrapped in spaces inside angle brackets (e.g. display-name < local-part@domain >)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DIRECT_TO_MX", + "weight": 0.0, + "description": "Message has been directly delivered from MUA to local MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_FIVE", + "weight": 0.0, + "description": "Message has 5-7 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_QUESTION", + "weight": 1.0, + "description": "Subject ends with a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS", + "weight": 2.0, + "description": "Recipients are not the same as RCPT TO: mail command", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TRACKER_ID", + "weight": 3.840000, + "description": "Spam string at the end of message to make statistics fault", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_ENVFROM", + "weight": 0.0, + "description": "From address is different to the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_EXTRA_SEMI", + "weight": 1.0, + "description": "Content-Type header ends with a semi-colon", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILLIST", + "weight": -0.200000, + "description": "Message seems to be from maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_TWO", + "weight": 0.0, + "description": "Message has X-Priority header set to 2", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_FIVE", + "weight": 0.0, + "description": "5-7 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_SUBJECT", + "weight": 2.0, + "description": "Subject header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CD_MM_BODY", + "weight": 2.0, + "description": "Content-Description header reads \"Mail message body\", commonly seen in spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "YANDEX_RU_MAILER", + "weight": 0.0, + "description": "Sent with Yandex webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_MISSING", + "weight": 2.500000, + "description": "Message was missing Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_NEEDS_ENCODING", + "weight": 1.0, + "description": "To header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEEDS_ENCODING", + "weight": 1.0, + "description": "From header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_NEEDS_ENCODING", + "weight": 1.0, + "description": "Subject needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_TO_ADDR", + "weight": 5.0, + "description": "Reply-To is the same as the To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EMAIL_HAS_TITLE", + "weight": 2.0, + "description": "Reply-To header has title", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ONE", + "weight": 0.0, + "description": "One recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EQ_FROM", + "weight": 0.0, + "description": "To address matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MIME", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_RECIPS", + "weight": 1.500000, + "description": "Recipients seems to be autogenerated (works if recipients count is more than 5)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_RECEIVED_mail_ru", + "weight": 4.0, + "description": "Fake HELO mail.ru in Received header from non-mail.ru sender address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XOIP", + "weight": 0.0, + "description": "Has X-Originating-IP header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EMPTY_SUBJECT", + "weight": 1.0, + "description": "Subject header is empty", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STOX_REPLY_TYPE", + "weight": 1.0, + "description": "Reply-type in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HEADER_CTYPE_ONLY", + "weight": 2.0, + "description": "Only Content-Type header without other MIME headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE", + "weight": -0.100000, + "description": "(Non) Delivery Status Notification", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SORTED_RECIPS", + "weight": 3.500000, + "description": "Recipients list seems to be sorted", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_POSTFIX_RECEIVED", + "weight": 3.0, + "description": "Invalid Postfix Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_PRVS", + "weight": 0.0, + "description": "Envelope From is a PRVS address that matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RECEIVED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIMEOLE", + "weight": 2.0, + "description": "Mime-OLE is needed but absent (e.g. fake Outlook or fake Exchange)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_HAS_DN", + "weight": 0.0, + "description": "From header has a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_NO_TLS_LAST", + "weight": 0.100000, + "description": "Last hop did not use encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_FROM_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RATWARE_MS_HASH", + "weight": 2.0, + "description": "Forged Exchange messages", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED_STRICT", + "weight": 4.0, + "description": "One received header with 'bad' patterns inside", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XM_CASE", + "weight": 0.500000, + "description": "X-mailer .vs. X-Mailer", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_PAST", + "weight": 1.0, + "description": "Message date is in the past", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_UNIQUE_HEADERS", + "weight": 7.0, + "description": "Repeated unique headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_THREE", + "weight": 0.0, + "description": "Message has X-Priority header set to 3 or 4", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_REPLYTO", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET_URL", + "weight": 7.0, + "description": "Mixed characters in a URL inside message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MV_CASE", + "weight": 0.500000, + "description": "Mime-Version .vs. MIME-Version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_UNDISC_RCPT", + "weight": 3.0, + "description": "Recipients are absent or undisclosed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER", + "weight": 0.0, + "description": "Sent with Apple Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_ALL", + "weight": 0.0, + "description": "All the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_BROKEN", + "weight": 1.700000, + "description": "Message had invalid Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_INVALID", + "weight": 2.0, + "description": "From header does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_FUTURE", + "weight": 4.0, + "description": "Message date is in the future", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_EXCESS_SPACE", + "weight": 1.0, + "description": "From header display name contains excess whitespace", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED2", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_THREE", + "weight": 0.0, + "description": "Message has 3-5 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_FROM", + "weight": 8.0, + "description": "Multiple addresses in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_CD_HEADER", + "weight": 0.0, + "description": "Has Content-Description header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_ALL", + "weight": 0.0, + "description": "All hops used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_ALL", + "weight": 0.0, + "description": "All of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_VIA_SMTP_AUTH", + "weight": 0.0, + "description": "Authenticated hand-off was seen in Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_RECIPIENTS", + "weight": 2.0, + "description": "To header display name is \"Recipients\"", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HTML_ONLY", + "weight": 0.200000, + "description": "Message has only an HTML part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_INTERSPIRE_SIG", + "weight": 1.0, + "description": "Has Interspire fingerprint", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_CURRENCY", + "weight": 1.0, + "description": "Subject contains currency", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_BOUNCE_WORDS", + "weight": 0.0, + "description": "Words/phrases typical for DSN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_REPLYTO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Reply-To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_TO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "phishing", + "rules": [ + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_REDIRECTOR_NESTED", + "weight": 1.0, + "description": "URL redirector nested limit has been reached" + }, + { + "symbol": "REDIRECTOR_FALSE", + "weight": 0.0, + "description": "Phishing exclusion symbol for known redirectors", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_EXCLUDED", + "weight": 0.0, + "description": "Phished URL found in exclusions list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHING", + "weight": 4.0, + "description": "Phished URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_OPENPHISH", + "weight": 7.0, + "description": "Phished URL found in openphish.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_GENERIC_SERVICE", + "weight": 0.0, + "description": "Phished URL found in generic service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_WHITELISTED", + "weight": 0.0, + "description": "Phishing exclusion symbol for known exceptions", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_PHISHTANK", + "weight": 7.0, + "description": "Phished URL found in phishtank.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessb64", + "rules": [ + { + "symbol": "FROM_EXCESS_BASE64", + "weight": 1.500000, + "description": "From header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_BASE64", + "weight": 1.500000, + "description": "Reply-To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_BASE64", + "weight": 1.500000, + "description": "To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CC_EXCESS_BASE64", + "weight": 1.500000, + "description": "Cc header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_BASE64", + "weight": 1.500000, + "description": "Subject header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "forwarding", + "rules": [ + { + "symbol": "FWD_MAILRU", + "weight": 0.0, + "description": "Message was forwarded by Mail.ru", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORWARDED", + "weight": 0.0, + "description": "Message was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_GOOGLE", + "weight": 0.0, + "description": "Message was forwarded by Google", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SIEVE", + "weight": 0.0, + "description": "Message was forwarded using Sieve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_CPANEL", + "weight": 0.0, + "description": "Message was forwarded using cPanel", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_YANDEX", + "weight": 0.0, + "description": "Message was forwarded by Yandex", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SRS", + "weight": 0.0, + "description": "Message was forwarded using Sender Rewriting Scheme (SRS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "url", + "rules": [ + { + "symbol": "HAS_FILE_URL", + "weight": 2.0, + "description": "Contains file:// URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BAD_UNICODE", + "weight": 3.0, + "description": "URL contains invalid Unicode", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_PASSWORD", + "weight": 2.0, + "description": "URL contains user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_OBFUSCATED_TEXT", + "weight": 5.0, + "description": "Obfuscated URL found in message text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_VERY_LONG", + "weight": 1.500000, + "description": "URL is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_HOMOGRAPH_ATTACK", + "weight": 5.0, + "description": "URL uses homograph attack (mixed scripts)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPICIOUS_TLD", + "weight": 3.0, + "description": "URL uses suspicious TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_REDIR", + "weight": 1.0, + "description": "Has google.com/url or alike Google redirection URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URI_COUNT_ODD", + "weight": 1.0, + "description": "Odd number of URIs in multipart/alternative message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_ZERO_WIDTH_SPACES", + "weight": 7.0, + "description": "URL contains zero-width spaces", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_LONG", + "weight": 3.0, + "description": "URL user field is long (>128 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_FIREBASE_URL", + "weight": 2.0, + "description": "Contains firebasestorage.googleapis.com URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_IPFS_GATEWAY_URL", + "weight": 6.0, + "description": "Message contains InterPlanetary File System (IPFS) gateway URL, likely malicious", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_RTL_OVERRIDE", + "weight": 6.0, + "description": "URL uses RTL override character", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_PRIVATE_IP", + "weight": 0.500000, + "description": "URL uses private IP range", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BACKSLASH_PATH", + "weight": 2.0, + "description": "URL uses backslashes", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP", + "weight": 1.500000, + "description": "URL uses numeric IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_VERY_LONG", + "weight": 5.0, + "description": "URL user field is very long (>256 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ONION_URI", + "weight": 0.0, + "description": "Contains .onion hidden service URI", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_EXCESSIVE_DOTS", + "weight": 2.0, + "description": "URL has excessive dots in hostname", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPECT_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NO_TLD", + "weight": 2.0, + "description": "URL has no TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OMOGRAPH_URL", + "weight": 5.0, + "description": "URL contains both latin and non-latin characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_MULTIPLE_AT_SIGNS", + "weight": 3.0, + "description": "URL has multiple @ signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP_USER", + "weight": 4.0, + "description": "URL uses numeric IP with user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GUC_PROXY_URI", + "weight": 1.0, + "description": "Has googleusercontent.com proxy URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rspamdbl", + "rules": [ + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocked", + "rules": [ + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocklistde", + "rules": [ + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime_types", + "rules": [ + { + "symbol": "MIME_DOUBLE_BAD_EXTENSION", + "weight": 3.0, + "description": "Bad extension cloaking", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TRACE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ARCHIVE_IN_ARCHIVE", + "weight": 5.0, + "description": "Archive within another archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_UNKNOWN", + "weight": 0.100000, + "description": "Missing or unknown content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_PGP", + "weight": -0.500000, + "description": "Message is encrypted with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_GOOD", + "weight": -0.100000, + "description": "Known content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOGUS_ENCRYPTED_AND_TEXT", + "weight": 10.0, + "description": "Bogus mix of encrypted and text/html payloads", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXTENSION", + "weight": 2.0, + "description": "Bad extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_EXE_IN_GEN_SPLIT_RAR", + "weight": 5.0, + "description": "EXE file in RAR archive with generic split extension (e.g. .001)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ENCRYPTED_ARCHIVE", + "weight": 2.0, + "description": "Encrypted archive in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD", + "weight": 1.0, + "description": "Known bad content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_SMIME", + "weight": -2.0, + "description": "Message is signed with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TYPES_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_UNICODE", + "weight": 2.0, + "description": "Filename with known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_PGP", + "weight": -2.0, + "description": "Message is signed with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_OBFUSCATED_ARCHIVE", + "weight": 2.0, + "description": "Archive has files with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_SMIME", + "weight": -0.500000, + "description": "Message is encrypted with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_ATTACHMENT", + "weight": 4.0, + "description": "Invalid attachment mime type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "antivirus", + "rules": [] + }, + { + "group": "spf", + "rules": [ + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "hfilter", + "rules": [ + { + "symbol": "HFILTER_URL_ONELINE", + "weight": 2.500000, + "description": "One line URL and text in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_3", + "weight": 2.0, + "description": "Helo host checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_1", + "weight": 0.500000, + "description": "Hostname checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_4", + "weight": 2.500000, + "description": "Helo host checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BAREIP", + "weight": 3.0, + "description": "Helo host is bare ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_4", + "weight": 2.500000, + "description": "Hostname checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_1", + "weight": 0.500000, + "description": "Helo host checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_5", + "weight": 3.0, + "description": "Helo host checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORESOLVE_MX", + "weight": 0.200000, + "description": "MX found in Helo and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_3", + "weight": 2.0, + "description": "Hostname checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_RCPT_BOUNCEMOREONE", + "weight": 1.500000, + "description": "Message from bounce and over 1 recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORES_A_OR_MX", + "weight": 1.500000, + "description": "FROM host no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_2", + "weight": 1.0, + "description": "Helo host checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BADIP", + "weight": 4.500000, + "description": "Helo host is very bad ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_2", + "weight": 1.0, + "description": "Hostname checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_5", + "weight": 3.0, + "description": "Hostname checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROM_BOUNCE", + "weight": 0.0, + "description": "Bounce message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_DNSFAIL", + "weight": 0.0, + "description": "PTR verification DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NOT_FQDN", + "weight": 2.0, + "description": "Helo not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORES_A_OR_MX", + "weight": 0.300000, + "description": "Helo no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORESOLVE_MX", + "weight": 0.500000, + "description": "MX found in FROM host and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NOT_FQDN", + "weight": 3.0, + "description": "FROM host not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_UNKNOWN", + "weight": 2.500000, + "description": "Unknown client hostname (PTR or FCrDNS verification failed)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_NONE", + "weight": 2.0, + "description": "Cannot resolve reverse DNS for sender's IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_IP_A", + "weight": 1.0, + "description": "Helo A IP != hostname IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_URL_ONLY", + "weight": 2.200000, + "description": "URL only in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "spamhaus", + "rules": [ + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ebl", + "rules": [ + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surblorg", + "rules": [ + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "uribl", + "rules": [ + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "external_services", + "rules": [] + }, + { + "group": "experimental", + "rules": [ + { + "symbol": "XM_UA_NO_VERSION", + "weight": 0.010000, + "description": "X-Mailer/User-Agent header has no version number", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "composite", + "rules": [ + { + "symbol": "SUSPICIOUS_AUTH_ORIGIN", + "weight": 0.0, + "description": "Message authenticated, but from a suspicios origin (potentially an injector)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_FORWARDING", + "weight": 0.0, + "description": "FORGED_RECIPIENTS & g:forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UNDISC_RCPTS_BULK", + "weight": 3.0, + "description": "Missing or undisclosed recipients with a bulk signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_URL_IN_SUSPICIOUS_MESSAGE", + "weight": 1.0, + "description": "Message contains redirector, anonymous or IPFS gateway URL and is marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_UNAUTH_PBL", + "weight": 2.0, + "description": "Relayed through Spamhaus PBL IP without sufficient authentication (possibly indicating an open relay)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISH_EMOTION", + "weight": 1.0, + "description": "Phish message with subject trying to address users emotion", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA_OR_FAIL", + "weight": 1.0, + "description": "No authenticating method SPF/DKIM/DMARC/ARC was successful", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL_ONLY", + "weight": 1.0, + "description": "Message only contains a redirector URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_VERP_SRS", + "weight": 0.0, + "description": "FORGED_SENDER & (ENVFROM_PRVS | ENVFROM_VERP)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ANON_DOMAIN", + "weight": 0.100000, + "description": "Contains one or more domains trying to disguise owner/destination", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS_MAILLIST", + "weight": 0.0, + "description": "Negate BROKEN_HEADERS when message comes via some mailing list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTOGEN_PHP_SPAMMY", + "weight": 1.0, + "description": "Message was generated by PHP script and contains some spam indicators", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple iOS Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "IP_SCORE_FREEMAIL", + "weight": 0.0, + "description": "Negate IP_SCORE when message comes from FreeMail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "VIOLATED_DIRECT_SPF", + "weight": 3.500000, + "description": "Has no Received (or no trusted received relays) and SPF policy fails or soft fails", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA", + "weight": 1.0, + "description": "Authenticating message via SPF/DKIM/DMARC/ARC not available", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM", + "weight": 2.0, + "description": "Reply-To is a Freemail address and it not match From header or SMTP From, also From is not another Freemail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_IN_OBFUSCATED_ARCHIVE", + "weight": 8.0, + "description": "Attachment with bad extension and archive that has filename with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAD_REP_POLICIES", + "weight": 0.100000, + "description": "Contains valid policies but are also marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID_ALLOWED", + "weight": 0.0, + "description": "MISSING_MID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_FAIL_FORWARDING", + "weight": 0.0, + "description": "g:forwarding & (R_SPF_SOFTFAIL | R_SPF_FAIL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID_ALLOWED", + "weight": 0.0, + "description": "INVALID_MSGID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_HI", + "weight": -1.0, + "description": "Sufficiently DKIM/ARC signed and received from IP with high trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_FORWARDING", + "weight": 0.0, + "description": "Forged sender, but message is forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_WITH_BAD_UNICODE", + "weight": 8.0, + "description": "Attachment with bad extension and filename that has known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_MED", + "weight": -0.500000, + "description": "Sufficiently DKIM/ARC signed and received from IP with medium trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_MIXED", + "weight": 0.0, + "description": "-R_DKIM_ALLOW & (R_DKIM_TEMPFAIL | R_DKIM_PERMFAIL | R_DKIM_REJECT)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE_NO_AUTH", + "weight": 1.0, + "description": "(AUTH_NA | AUTH_NA_OR_FAIL) & (BOUNCE | SUBJ_BOUNCE_WORDS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mid", + "rules": [ + { + "symbol": "MID_END_EQ_FROM_USER_PART", + "weight": 4.0, + "description": "Message-ID RHS (after @) and MIME from local part are the same", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_NO_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "fuzzy", + "rules": [ + { + "symbol": "FUZZY_DENIED", + "weight": 12.0, + "description": "Denied fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_PROB", + "weight": 5.0, + "description": "Probable fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_ENCRYPTION_REQUIRED", + "weight": 0.0, + "description": "Fuzzy encryption is required by a server", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_WHITE", + "weight": -2.100000, + "description": "Whitelisted fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_FORBIDDEN", + "weight": 0.0, + "description": "Fuzzy access denied", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_RATELIMITED", + "weight": 0.0, + "description": "Fuzzy rate limit is reached", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_UNKNOWN", + "weight": 5.0, + "description": "Generic fuzzy hash match, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_CALLBACK", + "weight": 0.0, + "description": "Fuzzy check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "senderscore", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "aliases", + "rules": [ + { + "symbol": "TAGGED_RCPT", + "weight": 0.0, + "description": "Recipient has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TAGGED_FROM", + "weight": 0.0, + "description": "From address has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTERNAL_MAIL", + "weight": 0.0, + "description": "Mail from local to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIASES_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_INBOUND", + "weight": 0.0, + "description": "Mail from external to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIAS_RESOLVED", + "weight": 0.0, + "description": "Address was resolved through aliases", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_OUTBOUND", + "weight": 0.0, + "description": "Mail from local to external domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "malware", + "rules": [ + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_FILENAME", + "weight": 9.0, + "description": "exe file in archive with clickbait filename", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_SUBJECT", + "weight": 9.0, + "description": "exe file in archive with clickbait subject", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISIDENTIFIED_RAR", + "weight": 4.0, + "description": "rar with wrong extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_ARCHIVE", + "weight": 1.500000, + "description": "exe file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_MISIDENTIFIED_RAR", + "weight": 5.0, + "description": "rar with wrong extension containing exe file", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_FILE_ARCHIVE_WITH_EXE", + "weight": 5.0, + "description": "single file container bearing executable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mailspike", + "rules": [ + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "compromised_hosts", + "rules": [ + { + "symbol": "URI_HIDDEN_PATH", + "weight": 1.0, + "description": "Message contains URI with a hidden path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XAW_SERVICE_ACCT", + "weight": 1.0, + "description": "Message originally from a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HIDDEN_SOURCE_OBJ", + "weight": 2.0, + "description": "UNIX hidden file/directory in path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_PHPMAILER_SIG", + "weight": 0.0, + "description": "PHPMailer signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WWW_DOT_DOMAIN", + "weight": 0.500000, + "description": "From/Sender/Reply-To or Envelope is @www.domain.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_SOURCE", + "weight": 0.0, + "description": "Has X-Source headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XAW", + "weight": 0.0, + "description": "Has X-Authentication-Warning header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PHP_SCRIPT", + "weight": 0.0, + "description": "Has X-PHP-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_SCRIPT_ROOT", + "weight": 1.0, + "description": "PHP Script executed by root UID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_XPS_PATTERN", + "weight": 0.0, + "description": "Message contains X-PHP-Script pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_AS", + "weight": 0.0, + "description": "Has X-Authenticated-Sender header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "COMPROMISED_ACCT_BULK", + "weight": 3.0, + "description": "Likely to be from a compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_EVAL", + "weight": 4.0, + "description": "Message sent using eval'd PHP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_POS", + "weight": 0.0, + "description": "Has X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_WP_URI", + "weight": 0.0, + "description": "Contains WordPress URIs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_FROM_INJECTOR", + "weight": 2.0, + "description": "Message is sent from a suspicios origin and showing signs of abuse, likely spam injected in compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_GMSV", + "weight": 0.0, + "description": "Has X-Get-Message-Sender-Via: header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Sender/From/Reply-To is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Envelope from is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_ANTIABUSE", + "weight": 0.0, + "description": "Has X-AntiAbuse headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WP_COMPROMISED", + "weight": 0.0, + "description": "URL that is pointing to a compromised WordPress installation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_WWW", + "weight": 0.500000, + "description": "Message-ID from www host", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "html", + "rules": [ + { + "symbol": "ZERO_FONT", + "weight": 1.0, + "description": "Zero sized font used", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_1", + "weight": 2.0, + "description": "Short HTML part (0..1K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_WHITE_ON_WHITE", + "weight": 4.0, + "description": "Message contains low contrast text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_2", + "weight": 1.0, + "description": "Short HTML part (1K..1.5K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_VISIBLE_CHECKS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_3", + "weight": 0.500000, + "description": "Short HTML part (1.5K..2K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_DATA_URI", + "weight": 0.0, + "description": "Has Data URI encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_IP", + "weight": 1.0, + "description": "HTML anchor points to an IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_EMPTY_IMAGE", + "weight": 2.0, + "description": "Message contains empty parts and image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MANY_INVISIBLE_PARTS", + "weight": 1.0, + "description": "Many parts are visually hidden", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SUSPICIOUS_IMAGES", + "weight": 5.0, + "description": "Message has high image to text ratio", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_HTTPS", + "weight": 0.500000, + "description": "The anchor text contains a distinct scheme compared to the target URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXT_CSS", + "weight": 1.0, + "description": "Message contains external CSS reference", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATA_URI_OBFU", + "weight": 2.0, + "description": "Uses Data URI encoding to obfuscate plain or HTML in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_META_REFRESH_URL", + "weight": 5.0, + "description": "Has HTML Meta refresh URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "subject", + "rules": [ + { + "symbol": "SUBJ_ALL_CAPS", + "weight": 3.0, + "description": "Subject contains mostly capital letters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LONG_SUBJ", + "weight": 3.0, + "description": "Subject is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_IN_SUBJECT", + "weight": 4.0, + "description": "Subject contains URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ungrouped", + "rules": [ + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLOCKLISTDE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_SHORT_PART", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UDF_COMPRESSION_500PLUS", + "weight": 9.0, + "description": "very well compressed img file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mua", + "rules": [ + { + "symbol": "FORGED_MUA_THEBAT_MSGID_UNKNOWN", + "weight": 3.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_KMAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from KMail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OPERA_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Opera Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OUTLOOK", + "weight": 3.0, + "description": "Forged Outlook MUA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY2", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY3", + "weight": 3.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY4", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY", + "weight": 5.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "whitelist", + "rules": [ + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blankspam", + "rules": [ + { + "symbol": "COMPLETELY_EMPTY", + "weight": 15.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SHORT_PART_BAD_HEADERS", + "weight": 7.0, + "description": "MISSING_ESSENTIAL_HEADERS & SINGLE_SHORT_PART", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_ESSENTIAL_HEADERS", + "weight": 7.0, + "description": "Common headers were entirely absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "content", + "rules": [ + { + "symbol": "PDF_TIMEOUT", + "weight": 0.0, + "description": "There is a PDF in the message that caused timeout in processing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_LONG_TRAILER", + "weight": 0.200000, + "description": "There is an PDF with a long trailer in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_JAVASCRIPT", + "weight": 0.100000, + "description": "There is an PDF with JavaScript in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_MANY_OBJECTS", + "weight": 0.0, + "description": "There is a PDF with too many objects in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_ENCRYPTED", + "weight": 0.300000, + "description": "There is an encrypted PDF in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_SUSPICIOUS", + "weight": 4.500000, + "description": "There is an PDF with suspicious properties in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "Message ID", + "rules": [ + { + "symbol": "MID_CONTAINS_TO", + "weight": 1.0, + "description": "Message-ID contains To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_MISSING_BRACKETS", + "weight": 0.500000, + "description": "Message-ID is missing <>'s", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_TO", + "weight": 1.0, + "description": "Message-ID RHS matches To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_NOT_FQDN", + "weight": 0.500000, + "description": "Message-ID RHS is not a fully-qualified domain name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROM", + "weight": 0.0, + "description": "Message-ID RHS matches From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_CONTAINS_FROM", + "weight": 1.0, + "description": "Message-ID contains From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_BARE_IP", + "weight": 2.0, + "description": "Message-ID RHS is a bare IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_IP_LITERAL", + "weight": 0.500000, + "description": "Message-ID RHS is an IP-literal", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROMTLD", + "weight": 0.0, + "description": "Message-ID RHS matches From domain tld", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers,mime", + "rules": [ + { + "symbol": "CHECK_TO_CC", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "scams", + "rules": [ + { + "symbol": "LEAKED_PASSWORD_SCAM_RE", + "weight": 0.0, + "description": "Contains BTC wallet address and malicious regexps", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_AFF", + "weight": 4.0, + "description": "Message exhibits strong characteristics of advance fee fraud (AFF a/k/a '419' spam) involving freemail addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTRODUCTION", + "weight": 2.0, + "description": "Sender introduces themselves", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_MDN", + "weight": 2.0, + "description": "Message delivery notification should go to freemail or disposable e-mail, but message was not sent from a freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BITCOIN_ADDR", + "weight": 0.0, + "description": "Message has a valid bitcoin wallet address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LEAKED_PASSWORD_SCAM", + "weight": 7.0, + "description": "Contains BTC wallet address and scam patterns", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "body", + "rules": [ + { + "symbol": "HAS_ATTACHMENT", + "weight": 0.0, + "description": "Message contains attachments", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_PARTS_DIFFER", + "weight": 1.0, + "description": "Text and HTML parts differ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + } +] diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go new file mode 100644 index 0000000..a0955ef --- /dev/null +++ b/pkg/analyzer/rspamd.go @@ -0,0 +1,174 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "math" + "regexp" + "strconv" + "strings" + + "git.happydns.org/happyDeliver/internal/model" +) + +// Default rspamd action thresholds (rspamd built-in defaults) +const ( + rspamdDefaultRejectThreshold float32 = 15 + rspamdDefaultAddHeaderThreshold float32 = 6 +) + +// RspamdAnalyzer analyzes rspamd results from email headers +type RspamdAnalyzer struct { + symbols map[string]string +} + +// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions +func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer { + return &RspamdAnalyzer{symbols: symbols} +} + +// AnalyzeRspamd extracts and analyzes rspamd results from email headers +func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult { + headers := email.GetRspamdHeaders() + if len(headers) == 0 { + return nil + } + + // Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report + _, hasSpamdResult := headers["X-Spamd-Result"] + _, hasRspamdScore := headers["X-Rspamd-Score"] + if !hasSpamdResult && !hasRspamdScore { + return nil + } + + result := &model.RspamdResult{ + Symbols: make(map[string]model.SpamTestDetail), + } + + // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) + // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." + if spamdResult, ok := headers["X-Spamd-Result"]; ok { + report := strings.ReplaceAll(spamdResult, "; ", ";\n") + result.Report = &report + a.parseSpamdResult(spamdResult, result) + } + + // Parse X-Rspamd-Score as override/fallback for score + if scoreHeader, ok := headers["X-Rspamd-Score"]; ok { + if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { + result.Score = float32(score) + } + } + + // Parse X-Rspamd-Server + if serverHeader, ok := headers["X-Rspamd-Server"]; ok { + server := strings.TrimSpace(serverHeader) + result.Server = &server + } + + // Populate symbol descriptions from the lookup map + if a.symbols != nil { + for name, sym := range result.Symbols { + if desc, ok := a.symbols[name]; ok { + sym.Description = &desc + result.Symbols[name] = sym + } + } + } + + // Derive IsSpam from score vs reject threshold. + if result.Threshold > 0 { + result.IsSpam = result.Score >= result.Threshold + } else { + result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold + } + + return result +} + +// parseSpamdResult parses the X-Spamd-Result header +// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." +func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) { + // Extract score and threshold from the first line + // e.g. "default: False [-3.91 / 15.00]" + scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`) + if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 { + if score, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Score = float32(score) + } + if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil { + result.Threshold = float32(threshold) + + // No threshold? use default AddHeaderThreshold + if result.Threshold <= 0 { + result.Threshold = rspamdDefaultAddHeaderThreshold + } + } + } + + // Parse is_spam from header (before we may get action from X-Rspamd-Action) + firstLine := strings.SplitN(header, ";", 2)[0] + if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") { + result.IsSpam = true + } + + // Parse symbols: SYMBOL(score)[params] + // Each symbol entry is separated by ";", so within each part we use a + // greedy match to capture params that may contain nested brackets. + symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`) + for _, part := range strings.Split(header, ";") { + part = strings.TrimSpace(part) + matches := symbolRe.FindStringSubmatch(part) + if len(matches) > 2 { + name := matches[1] + score, _ := strconv.ParseFloat(matches[2], 64) + sym := model.SpamTestDetail{ + Name: name, + Score: float32(score), + } + if len(matches) > 3 && matches[3] != "" { + params := matches[3] + sym.Params = ¶ms + } + result.Symbols[name] = sym + } + } +} + +// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale) +func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) { + if result == nil { + return 100, "" // rspamd not installed + } + + threshold := result.Threshold + percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold)))) + + if percentage > 100 { + return 100, "A+" + } else if percentage < 0 { + return 0, "F" + } + + // Linear scale between 0 and threshold + return percentage, ScoreToGrade(percentage) +} diff --git a/pkg/analyzer/rspamd_symbols.go b/pkg/analyzer/rspamd_symbols.go new file mode 100644 index 0000000..e50a452 --- /dev/null +++ b/pkg/analyzer/rspamd_symbols.go @@ -0,0 +1,105 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + _ "embed" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" +) + +//go:embed rspamd-symbols.json +var embeddedRspamdSymbols []byte + +// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON. +type rspamdSymbolGroup struct { + Group string `json:"group"` + Rules []rspamdSymbolEntry `json:"rules"` +} + +// rspamdSymbolEntry represents a single rspamd symbol entry. +type rspamdSymbolEntry struct { + Symbol string `json:"symbol"` + Description string `json:"description"` + Weight float64 `json:"weight"` +} + +// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map. +func parseRspamdSymbolsJSON(data []byte) map[string]string { + var groups []rspamdSymbolGroup + if err := json.Unmarshal(data, &groups); err != nil { + log.Printf("Failed to parse rspamd symbols JSON: %v", err) + return nil + } + + symbols := make(map[string]string, len(groups)*10) + for _, g := range groups { + for _, r := range g.Rules { + if r.Description != "" { + symbols[r.Symbol] = r.Description + } + } + } + return symbols +} + +// LoadRspamdSymbols loads rspamd symbol descriptions. +// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error. +func LoadRspamdSymbols(apiURL string) map[string]string { + if apiURL != "" { + if symbols := fetchRspamdSymbols(apiURL); symbols != nil { + return symbols + } + log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL) + } + return parseRspamdSymbolsJSON(embeddedRspamdSymbols) +} + +// fetchRspamdSymbols fetches symbol descriptions from the rspamd API. +func fetchRspamdSymbols(apiURL string) map[string]string { + url := strings.TrimRight(apiURL, "/") + "/symbols" + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + log.Printf("Error fetching rspamd symbols: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("rspamd API returned status %d", resp.StatusCode) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Error reading rspamd symbols response: %v", err) + return nil + } + + return parseRspamdSymbolsJSON(body) +} diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..9804f1d --- /dev/null +++ b/pkg/analyzer/rspamd_test.go @@ -0,0 +1,414 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "net/mail" + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer(nil) + email := &EmailMessage{Header: make(mail.Header)} + + result := analyzer.AnalyzeRspamd(email) + + if result != nil { + t.Errorf("Expected nil for email without rspamd headers, got %+v", result) + } +} + +func TestParseSpamdResult(t *testing.T) { + tests := []struct { + name string + header string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedSymbols map[string]float32 + expectedSymParams map[string]string + }{ + { + name: "Clean email negative score", + header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]", + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "DATE_IN_PAST": 0.10, + "ALL_TRUSTED": -1.00, + }, + expectedSymParams: map[string]string{ + "ALL_TRUSTED": "trusted", + }, + }, + { + name: "Spam email True flag", + header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)", + expectedScore: 16.50, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{ + "BAYES_99": 5.00, + "SPOOFED_SENDER": 3.50, + }, + expectedSymParams: map[string]string{ + "BAYES_99": "1.00", + }, + }, + { + name: "Zero threshold uses default", + header: "default: False [1.00 / 0.00]", + expectedScore: 1.00, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{}, + }, + { + name: "Symbol without params", + header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)", + expectedScore: 2.00, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "MISSING_DATE": 1.00, + }, + }, + { + name: "Case-insensitive true flag", + header: "default: true [8.00 / 6.00]", + expectedScore: 8.00, + expectedThreshold: 6.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{}, + }, + { + name: "Zero threshold with symbols containing nested brackets in params", + header: "default: False [0.90 / 0.00];\n" + + "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" + + "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" + + "\tMIME_TRACE(0.00)[0:+,1:+,2:~]", + expectedScore: 0.90, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "ARC_REJECT": 1.00, + "MIME_GOOD": -0.10, + "MIME_TRACE": 0.00, + }, + expectedSymParams: map[string]string{ + "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}", + "MIME_GOOD": "multipart/alternative,text/plain", + "MIME_TRACE": "0:+,1:+,2:~", + }, + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &model.RspamdResult{ + Symbols: make(map[string]model.SpamTestDetail), + } + analyzer.parseSpamdResult(tt.header, result) + + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + for symName, expectedScore := range tt.expectedSymbols { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found", symName) + continue + } + if sym.Score != expectedScore { + t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore) + } + } + for symName, expectedParam := range tt.expectedSymParams { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found for params check", symName) + continue + } + if sym.Params == nil { + t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam) + } else if *sym.Params != expectedParam { + t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam) + } + } + }) + } +} + +func TestAnalyzeRspamd(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedServer *string + expectedSymCount int + }{ + { + name: "Full headers clean email", + headers: map[string]string{ + "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]", + "X-Rspamd-Score": "-3.91", + "X-Rspamd-Server": "mail.example.com", + }, + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedServer: func() *string { s := "mail.example.com"; return &s }(), + expectedSymCount: 1, + }, + { + name: "X-Rspamd-Score overrides spamd result score", + headers: map[string]string{ + "X-Spamd-Result": "default: False [2.00 / 15.00]", + "X-Rspamd-Score": "3.50", + }, + expectedScore: 3.50, + expectedThreshold: 15.00, + expectedIsSpam: false, + }, + { + name: "Spam email above threshold", + headers: map[string]string{ + "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)", + "X-Rspamd-Score": "16.00", + }, + expectedScore: 16.00, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymCount: 1, + }, + { + name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold", + headers: map[string]string{ + "X-Rspamd-Score": "2.00", + }, + expectedScore: 2.00, + expectedIsSpam: false, + }, + { + name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold", + headers: map[string]string{ + "X-Rspamd-Score": "7.00", + }, + expectedScore: 7.00, + expectedIsSpam: true, + }, + { + name: "Server header is trimmed", + headers: map[string]string{ + "X-Rspamd-Score": "1.00", + "X-Rspamd-Server": " rspamd-01 ", + }, + expectedScore: 1.00, + expectedServer: func() *string { s := "rspamd-01"; return &s }(), + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{Header: make(mail.Header)} + for k, v := range tt.headers { + email.Header[k] = []string{v} + } + + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + if tt.expectedServer != nil { + if result.Server == nil { + t.Errorf("Server = nil, want %q", *tt.expectedServer) + } else if *result.Server != *tt.expectedServer { + t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer) + } + } + if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount { + t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount) + } + }) + } +} + +func TestCalculateRspamdScore(t *testing.T) { + tests := []struct { + name string + result *model.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &model.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &model.RspamdResult{ + Score: 0, + Threshold: 15.00, + }, + // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A" + expectedScore: 100, + expectedGrade: "A", + }, + { + name: "Score at threshold (half of 2*threshold)", + result: &model.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &model.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &model.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer(nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, grade := analyzer.CalculateRspamdScore(tt.result) + + if score != tt.expectedScore { + t.Errorf("Score = %d, want %d", score, tt.expectedScore) + } + if tt.expectedGrade != "" && grade != tt.expectedGrade { + t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade) + } + }) + } +} + +const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00]; + BAYES_HAM(-3.00)[99%]; + RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from]; + R_DKIM_ALLOW(-0.20)[example.com:s=dkim]; + FROM_HAS_DN(0.00)[]; + MIME_GOOD(-0.10)[text/plain]; +X-Rspamd-Score: -3.91 +X-Rspamd-Server: rspamd-01.example.com +Date: Mon, 09 Mar 2026 10:00:00 +0000 +From: sender@example.com +To: test@happydomain.org +Subject: Test email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +Hello world` + +func TestAnalyzeRspamdRealEmail(t *testing.T) { + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + analyzer := NewRspamdAnalyzer(nil) + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.IsSpam { + t.Error("Expected IsSpam=false") + } + if result.Score != -3.91 { + t.Errorf("Score = %v, want -3.91", result.Score) + } + if result.Threshold != 15.00 { + t.Errorf("Threshold = %v, want 15.00", result.Threshold) + } + if result.Server == nil || *result.Server != "rspamd-01.example.com" { + t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server) + } + + expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"} + for _, sym := range expectedSymbols { + if _, ok := result.Symbols[sym]; !ok { + t.Errorf("Symbol %s not found", sym) + } + } + + score, _ := analyzer.CalculateRspamdScore(result) + if score != 100 { + t.Errorf("CalculateRspamdScore = %d, want 100", score) + } +} + diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index ae91d4f..0baeab7 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -22,7 +22,7 @@ package analyzer import ( - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // ScoreToGrade converts a percentage score (0-100) to a letter grade @@ -45,7 +45,57 @@ func ScoreToGrade(score int) string { } } -// ScoreToReportGrade converts a percentage score to an api.ReportGrade -func ScoreToReportGrade(score int) api.ReportGrade { - return api.ReportGrade(ScoreToGrade(score)) +// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation +func ScoreToGradeKind(score int) string { + switch { + case score > 100: + return "A+" + case score >= 90: + return "A" + case score >= 80: + return "B" + case score >= 60: + return "C" + case score >= 45: + return "D" + case score >= 30: + return "E" + default: + return "F" + } +} + +// ScoreToReportGrade converts a percentage score to an model.ReportGrade +func ScoreToReportGrade(score int) model.ReportGrade { + return model.ReportGrade(ScoreToGrade(score)) +} + +// 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 + } +} + +// MinGrade returns the minimal (worse) grade between the two given grades +func MinGrade(a, b string) string { + if gradeRank(a) <= gradeRank(b) { + return a + } + return b } diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index cb80fe6..96f60dd 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -27,7 +27,8 @@ import ( "strconv" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // SpamAssassinAnalyzer analyzes SpamAssassin results from email headers @@ -39,18 +40,26 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { } // AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers -func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult { +func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult { headers := email.GetSpamAssassinHeaders() if len(headers) == 0 { return nil } - result := &api.SpamAssassinResult{ - TestDetails: make(map[string]api.SpamTestDetail), + // Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report + _, hasStatus := headers["X-Spam-Status"] + _, hasScore := headers["X-Spam-Score"] + _, hasFlag := headers["X-Spam-Flag"] + if !hasStatus && !hasScore && !hasFlag { + return nil + } + + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } // Parse X-Spam-Status header - if statusHeader, ok := headers["X-Spam-Status"]; ok { + if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" { a.parseSpamStatus(statusHeader, result) } @@ -68,13 +77,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa // Parse X-Spam-Report header for detailed test results if reportHeader, ok := headers["X-Spam-Report"]; ok { - result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1)) + result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1)) a.parseSpamReport(reportHeader, result) } // Parse X-Spam-Checker-Version if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok { - result.Version = api.PtrTo(strings.TrimSpace(versionHeader)) + result.Version = utils.PtrTo(strings.TrimSpace(versionHeader)) } return result @@ -82,7 +91,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa // parseSpamStatus parses the X-Spam-Status header // Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no -func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) { +func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) { // Check if spam (first word) parts := strings.SplitN(header, ",", 2) if len(parts) > 0 { @@ -126,7 +135,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs // * 0.0 TEST_NAME Description line 1 // * continuation line 2 // * continuation line 3 -func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) { +func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) { segments := strings.Split(report, "*") // Regex to match test lines: score TEST_NAME Description @@ -148,7 +157,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs // Save previous test if exists if currentTestName != "" { description := strings.TrimSpace(currentDescription.String()) - detail := api.SpamTestDetail{ + detail := model.SpamTestDetail{ Name: currentTestName, Score: result.TestDetails[currentTestName].Score, Description: &description, @@ -166,7 +175,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs currentDescription.WriteString(description) // Initialize with score - result.TestDetails[testName] = api.SpamTestDetail{ + result.TestDetails[testName] = model.SpamTestDetail{ Name: testName, Score: float32(score), } @@ -183,7 +192,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs // Save the last test if exists if currentTestName != "" { description := strings.TrimSpace(currentDescription.String()) - detail := api.SpamTestDetail{ + detail := model.SpamTestDetail{ Name: currentTestName, Score: result.TestDetails[currentTestName].Score, Description: &description, @@ -193,7 +202,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs } // CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability -func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) { +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) { if result == nil { return 100, "" // No spam scan results, assume good } diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index b539f24..d5e67a9 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -27,7 +27,8 @@ import ( "strings" "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseSpamStatus(t *testing.T) { @@ -77,8 +78,8 @@ func TestParseSpamStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := &api.SpamAssassinResult{ - TestDetails: make(map[string]api.SpamTestDetail), + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } analyzer.parseSpamStatus(tt.header, result) @@ -115,27 +116,27 @@ func TestParseSpamReport(t *testing.T) { ` analyzer := NewSpamAssassinAnalyzer() - result := &api.SpamAssassinResult{ - TestDetails: make(map[string]api.SpamTestDetail), + result := &model.SpamAssassinResult{ + TestDetails: make(map[string]model.SpamTestDetail), } analyzer.parseSpamReport(report, result) - expectedTests := map[string]api.SpamTestDetail{ + expectedTests := map[string]model.SpamTestDetail{ "BAYES_99": { Name: "BAYES_99", Score: 5.0, - Description: api.PtrTo("Bayes spam probability is 99 to 100%"), + Description: utils.PtrTo("Bayes spam probability is 99 to 100%"), }, "SPOOFED_SENDER": { Name: "SPOOFED_SENDER", Score: 3.5, - Description: api.PtrTo("From address doesn't match envelope sender"), + Description: utils.PtrTo("From address doesn't match envelope sender"), }, "ALL_TRUSTED": { Name: "ALL_TRUSTED", Score: -1.0, - Description: api.PtrTo("All mail servers are trusted"), + Description: utils.PtrTo("All mail servers are trusted"), }, } @@ -157,7 +158,7 @@ func TestParseSpamReport(t *testing.T) { func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string - result *api.SpamAssassinResult + result *model.SpamAssassinResult expectedScore int minScore int maxScore int @@ -169,7 +170,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Excellent score (negative)", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: -2.5, RequiredScore: 5.0, }, @@ -177,7 +178,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Good score (below threshold)", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 2.0, RequiredScore: 5.0, }, @@ -185,7 +186,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Score at threshold", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 5.0, RequiredScore: 5.0, }, @@ -193,7 +194,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Above threshold (spam)", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 6.0, RequiredScore: 5.0, }, @@ -201,7 +202,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "High spam score", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 12.0, RequiredScore: 5.0, }, @@ -209,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Very high spam score", - result: &api.SpamAssassinResult{ + result: &model.SpamAssassinResult{ Score: 20.0, RequiredScore: 5.0, }, diff --git a/web/package-lock.json b/web/package-lock.json index 4ea7ea6..54689ad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,473 +13,65 @@ "bootstrap-icons": "^1.13.1" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^10.0.0", + "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", - "@sveltejs/vite-plugin-svelte": "^6.2.0", - "@types/node": "^22", - "eslint": "^9.36.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^24.0.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", - "typescript": "^5.9.2", + "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", - "vite": "^7.1.10", - "vitest": "^3.2.4" + "vite": "^8.0.0", + "vitest": "^4.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", - "cpu": [ - "ppc64" - ], + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", - "cpu": [ - "arm" - ], + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -509,9 +101,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -519,19 +111,19 @@ } }, "node_modules/@eslint/compat": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", - "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.3.tgz", + "integrity": "sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^8.40 || 9" + "eslint": "^8.40 || 9 || 10" }, "peerDependenciesMeta": { "eslint": { @@ -540,128 +132,99 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hey-api/codegen-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz", - "integrity": "sha512-c7VjBy/8ed0EVLNgaeS9Xxams1Tuv/WK/b4xXH3Qr4wjzYeJUtxOcoP8YdwNLavqKP8pGiuctjX2Z1Pwc4jMgQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz", + "integrity": "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=22.10.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -671,9 +234,9 @@ } }, "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.0.tgz", - "integrity": "sha512-BMnIuhVgNmSudadw1GcTsP18Yk5l8FrYrg/OSYNxz0D2E0vf4D5e4j5nUbuY8MU6p1vp7ev0xrfP6A/NWazkzQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz", + "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==", "dev": true, "license": "MIT", "dependencies": { @@ -690,27 +253,27 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.85.2.tgz", - "integrity": "sha512-pNu+DOtjeXiGhMqSQ/mYadh6BuKR/QiucVunyA2P7w2uyxkfCJ9sHS20Y72KHXzB3nshKJ9r7JMirysoa50SJg==", + "version": "0.86.10", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.10.tgz", + "integrity": "sha512-Ns0dTJp/RUrOMPiJsO4/1E2Sa3VZ1iw2KCdG6PDbd9vLwOXEYW2UmiWMDPOTInLCYB+f8FLMF9T25jtfQe7AZg==", "dev": true, "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "^0.2.0", - "@hey-api/json-schema-ref-parser": "1.2.0", + "@hey-api/codegen-core": "^0.3.2", + "@hey-api/json-schema-ref-parser": "1.2.1", "ansi-colors": "4.1.3", - "c12": "3.3.0", + "c12": "3.3.1", "color-support": "1.1.3", - "commander": "13.0.0", + "commander": "14.0.1", "handlebars": "4.7.8", - "open": "10.1.2", + "open": "10.2.0", "semver": "7.7.2" }, "bin": { - "openapi-ts": "bin/index.cjs" + "openapi-ts": "bin/run.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -828,42 +391,31 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@polka/url": { @@ -884,24 +436,10 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], @@ -910,12 +448,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ "arm64" ], @@ -924,12 +465,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ "x64" ], @@ -938,26 +482,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ "x64" ], @@ -966,12 +499,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "cpu": [ "arm" ], @@ -980,26 +516,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ "arm64" ], @@ -1008,12 +533,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ "arm64" ], @@ -1022,26 +550,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ "ppc64" ], @@ -1050,40 +567,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ "s390x" ], @@ -1092,12 +584,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], @@ -1106,12 +601,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ "x64" ], @@ -1120,12 +618,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ "arm64" ], @@ -1134,12 +635,32 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ "arm64" ], @@ -1148,26 +669,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], @@ -1176,33 +686,29 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1220,25 +726,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.47.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", - "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.3.2", + "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", + "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "bin": { @@ -1249,64 +753,60 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true + }, + "typescript": { + "optional": true } } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", - "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", - "debug": "^4.4.1", "deepmerge": "^4.3.1", - "magic-string": "^0.30.17", - "vitefu": "^1.1.1" + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" }, "engines": { "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", - "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "debug": "^4.4.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "tslib": "^2.4.0" } }, "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/cookie": { @@ -1323,6 +823,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1338,113 +845,31 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "undici-types": "~7.16.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "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", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.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.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } + "license": "MIT" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1454,52 +879,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.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.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" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "dev": true, "license": "MIT", "engines": { @@ -1510,94 +893,15 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "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.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" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1608,39 +912,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1652,42 +957,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1695,40 +1000,36 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1747,9 +1048,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1773,22 +1074,6 @@ "node": ">=6" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1797,9 +1082,9 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1827,11 +1112,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bootstrap": { "version": "5.3.8", @@ -1869,27 +1157,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, "node_modules/bundle-name": { @@ -1909,19 +1186,19 @@ } }, "node_modules/c12": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz", - "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.1.tgz", + "integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", - "dotenv": "^17.2.2", + "dotenv": "^17.2.3", "exsolve": "^1.0.7", "giget": "^2.0.0", - "jiti": "^2.5.1", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", @@ -1937,70 +1214,16 @@ } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2037,26 +1260,6 @@ "node": ">=6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -2068,26 +1271,19 @@ } }, "node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "dev": true, "license": "MIT" }, @@ -2101,6 +1297,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2157,16 +1360,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2185,9 +1378,9 @@ } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -2202,9 +1395,9 @@ } }, "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -2241,17 +1434,27 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", - "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "dev": true, "license": "MIT" }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2261,55 +1464,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2324,34 +1478,30 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2361,8 +1511,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2370,7 +1519,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -2401,9 +1550,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.5.tgz", - "integrity": "sha512-4KRG84eAHQfYd9OjZ1K7sCHy0nox+9KwT+s5WCCku3jTim5RV4tVENob274nCwIaApXsYPKAUAZFBxKZ3Wyfjw==", + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.16.0.tgz", + "integrity": "sha512-DJXxqpYZUxcE0SfYo8EJzV2ZC+zAD7fJp1n1HwcEMRR1cOEUYvjT9GuzJeNghMjgb7uxuK3IJAzI+x6zzUxO5A==", "dev": true, "license": "MIT", "dependencies": { @@ -2425,7 +1574,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0", + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2434,31 +1583,46 @@ } } }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2472,27 +1636,27 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2503,13 +1667,14 @@ } }, "node_modules/esrap": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", - "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" } }, "node_modules/esrecurse": { @@ -2556,9 +1721,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2566,9 +1731,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2579,36 +1744,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2623,16 +1758,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2664,19 +1789,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2709,9 +1821,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2762,9 +1874,9 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -2774,13 +1886,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -2803,16 +1908,6 @@ "uglify-js": "^3.1.4" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2823,23 +1918,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2908,16 +1986,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2929,9 +1997,9 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -2961,17 +2029,10 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3043,6 +2104,267 @@ "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, + "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, + "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, + "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, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -3077,71 +2399,36 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3222,25 +2509,41 @@ "license": "MIT" }, "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", "dev": true, "license": "MIT", "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", + "citty": "^0.2.0", "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" + "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" }, "engines": { - "node": "^14.16.0 || >=16.10.0" + "node": ">=18" } }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "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", @@ -3249,16 +2552,16 @@ "license": "MIT" }, "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "wsl-utils": "^0.1.0" }, "engines": { "node": ">=18" @@ -3317,19 +2620,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3357,20 +2647,10 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/perfect-debounce": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", - "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "dev": true, "license": "MIT" }, @@ -3382,13 +2662,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3407,9 +2687,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3426,7 +2706,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3467,9 +2746,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -3531,9 +2810,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -3555,12 +2834,11 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3572,9 +2850,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3592,27 +2870,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -3638,67 +2895,38 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/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.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, "node_modules/run-applescript": { @@ -3714,30 +2942,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3765,9 +2969,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "dev": true, "license": "MIT" }, @@ -3844,69 +3048,31 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/svelte": { - "version": "5.41.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.41.0.tgz", - "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==", + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz", + "integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==", "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.6.4", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3917,9 +3083,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", - "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", "dev": true, "license": "MIT", "dependencies": { @@ -3941,9 +3107,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", - "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", + "integrity": "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -3952,11 +3118,12 @@ "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.18.3" + "pnpm": "10.30.3" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -3970,6 +3137,54 @@ } } }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3978,11 +3193,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4001,62 +3219,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/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", @@ -4068,9 +3240,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4080,6 +3252,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4094,12 +3274,11 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4109,16 +3288,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz", - "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.1", - "@typescript-eslint/parser": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4128,10 +3307,203 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/typescript-eslint/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -4147,9 +3519,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -4171,18 +3543,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", - "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "bin": { @@ -4199,9 +3569,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.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", @@ -4214,15 +3585,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -4246,46 +3620,10 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", "dev": true, "license": "MIT", "workspaces": [ @@ -4294,7 +3632,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -4303,65 +3641,79 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4372,26 +3724,16 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -4445,19 +3787,20 @@ "dev": true, "license": "MIT" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "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": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" }, "engines": { - "node": ">= 14.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { diff --git a/web/package.json b/web/package.json index d0a2578..66b2c8c 100644 --- a/web/package.json +++ b/web/package.json @@ -16,25 +16,25 @@ "generate:api": "openapi-ts" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^10.0.0", + "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", - "@sveltejs/vite-plugin-svelte": "^6.2.0", - "@types/node": "^22", - "eslint": "^9.36.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^24.0.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", - "typescript": "^5.9.2", + "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", - "vite": "^7.1.10", - "vitest": "^3.2.4" + "vite": "^8.0.0", + "vitest": "^4.0.0" }, "dependencies": { "bootstrap": "^5.3.8", diff --git a/web/routes.go b/web/routes.go index f67b453..056115d 100644 --- a/web/routes.go +++ b/web/routes.go @@ -24,9 +24,9 @@ package web import ( "encoding/json" "flag" + "fmt" "io" "io/fs" - "io/ioutil" "log" "net/http" "net/url" @@ -54,6 +54,26 @@ func init() { 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 { @@ -73,6 +93,13 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/", serveOrReverse("/", cfg)) + router.GET("/blacklist/", serveOrReverse("/", cfg)) + router.GET("/blacklist/:ip", serveOrReverse("/", cfg)) + router.GET("/domain/", serveOrReverse("/", cfg)) + router.GET("/domain/:domain", serveOrReverse("/", cfg)) + router.GET("/test/", serveOrReverse("/", cfg)) + router.GET("/test/:testid", serveOrReverse("/", cfg)) + router.GET("/history/", serveOrReverse("/", cfg)) router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/img/*path", serveOrReverse("", cfg)) @@ -121,15 +148,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { } } - v, _ := ioutil.ReadAll(resp.Body) + v, _ := io.ReadAll(resp.Body) - v2 := strings.Replace(strings.Replace(string(v), "", "{{ .Head }}", 1), "", "{{ .Body }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Body": CustomBodyHTML, - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } @@ -147,16 +175,18 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { if indexTpl == nil { // Create template from file f, _ := Assets.Open("index.html") - v, _ := ioutil.ReadAll(f) + v, _ := io.ReadAll(f) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) } // Serve template if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } diff --git a/web/src/app.css b/web/src/app.css index 1472994..dca80a5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,6 +1,9 @@ :root { --bs-primary: #1cb487; --bs-primary-rgb: 28, 180, 135; + --bs-link-color-rgb: 28, 180, 135; + --bs-link-hover-color-rgb: 17, 112, 84; + --bs-tertiary-bg: #e7e8e8; } body { @@ -8,6 +11,10 @@ body { -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } +.bg-tertiary { + background-color: var(--bs-tertiary-bg); +} + /* Animations */ @keyframes fadeIn { from { diff --git a/web/src/app.html b/web/src/app.html index 1966776..9e3bf88 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -3,9 +3,38 @@ + + + + + + + + + + + + + %sveltekit.head% +
%sveltekit.body%
diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..fb235b0 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + h + + + + + diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index cf1b80f..46a4d2d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -1,23 +1,38 @@
-
+

@@ -80,276 +103,465 @@

+ {#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 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} - -
-
- {#if authentication.dkim && authentication.dkim.length > 0} - + +
+
+ {#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[0].result} + DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""} + + {dkim.result} - {#if authentication.dkim[0].domain} + {#if dkim.domain}
Domain: - {authentication.dkim[0].domain} + {dkim.domain}
{/if} - {#if authentication.dkim[0].selector} + {#if dkim.selector}
Selector: - {authentication.dkim[0].selector} + {dkim.selector}
{/if} - {#if authentication.dkim[0].details} -
{authentication.dkim[0].details}
- {/if} -
- {: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 dkim.details} +
{dkim.details}
{/if}
-
- {/if} - - - {#if false && 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} - - -
+ {/each} + {:else}
- {#if authentication.dmarc} - -
- DMARC - - {authentication.dmarc.result} - - {#if authentication.dmarc.domain} -
- Domain: - {authentication.dmarc.domain} -
- {/if} - {#snippet DMARCPolicy(policy)} -
- Policy: - - {policy} - -
- {/snippet} - {#if authentication.dmarc.result != "none"} - {#if 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} - {@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} + +
+ 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 index f9aee88..889e24f 100644 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -1,8 +1,8 @@
-
-

+
+

Blacklist Checks @@ -34,11 +33,7 @@

- {#if receivedChain} - - {/if} - -
+
{#each Object.entries(blacklists) as [ip, checks]}
@@ -49,9 +44,19 @@ {#each checks as check} - - - {check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')} + + + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Clean"} {check.rbl} diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index b5fc380..51c4e5b 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -1,6 +1,7 @@
-
+

@@ -35,16 +36,28 @@
- + HTML Part
- + Plaintext Part
- {#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'} + {#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
- + Unsubscribe Link
{/if} @@ -73,7 +86,14 @@
Content Issues
{#each contentAnalysis.html_issues as issue} -
+
{issue.type} @@ -117,11 +137,17 @@ {/if} - + {link.status} - {link.http_code || '-'} + {link.http_code || "-"} {/each} @@ -145,11 +171,11 @@ {#each contentAnalysis.images as image} - {image.src || '-'} + {image.src || "-"} {#if image.has_alt} - {image.alt_text || 'Present'} + {image.alt_text || "Present"} {:else} Missing diff --git a/web/src/lib/components/DkimRecordsDisplay.svelte b/web/src/lib/components/DkimRecordsDisplay.svelte index fad7205..11a1b00 100644 --- a/web/src/lib/components/DkimRecordsDisplay.svelte +++ b/web/src/lib/components/DkimRecordsDisplay.svelte @@ -1,8 +1,8 @@
-
+

@@ -60,69 +69,85 @@

{/if} - - {#if receivedChain && receivedChain.length > 0} -
-

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

-
- {/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} - + + +
+ + +
+
+

+ 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} -
+ +
+

+ 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} @@ -134,8 +159,10 @@ /> {/if} - - + {#if !domainOnly} + + + {/if} diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte index 0f62dd2..5b5f051 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -1,4 +1,6 @@ -
+
+
+ {/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 index b503fec..f9d1f78 100644 --- a/web/src/lib/components/GradeDisplay.svelte +++ b/web/src/lib/components/GradeDisplay.svelte @@ -1,7 +1,7 @@ - + {#if grade} {grade} {:else} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 54f6743..73c39e8 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -1,21 +1,21 @@
-
+

@@ -38,7 +38,14 @@
Issues
{#each headerAnalysis.issues as issue} -
+
{issue.header} @@ -58,80 +65,282 @@ {/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}
- {#if xAlignedFrom} - - {:else} - - {/if} + Domain Alignment
-

- Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. +

+ 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.

-
-
- Strict Alignment -
- - - {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} - -
-
Exact domain match
-
-
- Relaxed Alignment -
- - - {headerAnalysis.domain_alignment.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} -
-
- 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 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 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}
- {/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} @@ -152,9 +361,9 @@ {#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']; + 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]} @@ -163,23 +372,39 @@ {#if check.importance} - + {check.importance} {/if} - + {#if check.present && check.valid !== undefined} - + {:else} - {/if} - {check.value || '-'} + {check.value || "-"} {#if check.issues && check.issues.length > 0} {#each check.issues as issue}
diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte new file mode 100644 index 0000000..737d025 --- /dev/null +++ b/web/src/lib/components/HistoryTable.svelte @@ -0,0 +1,72 @@ + + +
+ + + + + + + + + + + + {#each tests as test} + goto(`/test/${test.test_id}`)}> + + + + + + + {/each} + +
GradeScoreDomainDate
+ + + {test.score}% + + {#if test.from_domain} + {test.from_domain} + {:else} + - + {/if} + + {formatDate(test.created_at)} + + +
+
+ + diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte index f0a8088..893cae6 100644 --- a/web/src/lib/components/MxRecordsDisplay.svelte +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -1,10 +1,11 @@ {#if ptrRecords && ptrRecords.length > 0} @@ -63,15 +68,31 @@
Forward Resolution (A/AAAA): {#each ptrForwardRecords as ip} -
- {#if senderIp && ip === senderIp} - Match - {:else} - Different - {/if} - {ip} -
+ {#if ip === senderIp || !fcrDnsIsValid || showDifferent} +
+ {#if senderIp && ip === senderIp} + Match + {:else} + Different + {/if} + {ip} +
+ {/if} {/each} + {#if fcrDnsIsValid && differentCount > 0} +
+ +
+ {/if}
{#if fcrDnsIsValid}
diff --git a/web/src/lib/components/PtrRecordsDisplay.svelte b/web/src/lib/components/PtrRecordsDisplay.svelte index 66b4940..c88d7cd 100644 --- a/web/src/lib/components/PtrRecordsDisplay.svelte +++ b/web/src/lib/components/PtrRecordsDisplay.svelte @@ -27,9 +27,9 @@

- PTR records (reverse DNS) map 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. + 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}
diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte new file mode 100644 index 0000000..4c2795b --- /dev/null +++ b/web/src/lib/components/RspamdCard.svelte @@ -0,0 +1,153 @@ + + +
+
+

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

+
+
+
+
+ Score: + + {rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)} + +
+
+ Classified as: + + {rspamd.is_spam ? "SPAM" : "HAM"} + +
+
+ Action: + + {effectiveAction.label} + +
+
+ + {#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0} +
+
+ + + + + + + + + + {#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]} + 0 + ? "table-warning" + : symbol.score < 0 + ? "table-success" + : ""} + > + + + + + {/each} + +
SymbolScoreDescription
+ {symbolName} + {#if symbol.params} + + {symbol.params} + + {/if} + + 0 + ? "text-danger fw-bold" + : symbol.score < 0 + ? "text-success fw-bold" + : "text-muted"} + > + {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} + + {symbol.description ?? ""}
+
+
+ {/if} + + {#if rspamd.report} +
+ Raw Report +
{rspamd.report}
+
+ {/if} +
+
+ + diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 562d6aa..7a80dc4 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,5 +1,6 @@ -
+
{#if reanalyzing} @@ -55,7 +56,11 @@
-
+
DNS
@@ -63,31 +68,56 @@
-
-
-
-
+
Spam Score
@@ -95,8 +125,15 @@
-
- +
+ Content
@@ -117,4 +154,9 @@ transform: translateY(-2px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } + + :global([data-bs-theme="dark"]) .summary-card:hover { + background-color: #495057 !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index d70a1bd..cc88c23 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -1,32 +1,31 @@
-
+

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

@@ -60,14 +59,26 @@ {#each Object.entries(spamassassin.test_details) as [testName, detail]} - 0 ? 'table-warning' : detail.score < 0 ? 'table-success' : ''}> + 0 + ? "table-warning" + : detail.score < 0 + ? "table-success" + : ""} + > {testName} - 0 ? 'text-danger fw-bold' : detail.score < 0 ? 'text-success fw-bold' : 'text-muted'}> - {detail.score > 0 ? '+' : ''}{detail.score.toFixed(1)} + 0 + ? "text-danger fw-bold" + : detail.score < 0 + ? "text-success fw-bold" + : "text-muted"} + > + {detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)} - {detail.description || ''} + {detail.description || ""} {/each} @@ -79,7 +90,11 @@ Tests Triggered:
{#each spamassassin.tests as test} - {test} + {test} {/each}
@@ -88,7 +103,10 @@ {#if spamassassin.report}
Raw Report -
{spamassassin.report}
+
{spamassassin.report}
{/if}
@@ -106,4 +124,15 @@ details summary:hover { color: var(--bs-primary); } + + /* Darker table colors in dark mode */ + :global([data-bs-theme="dark"]) .table-warning { + --bs-table-bg: rgba(255, 193, 7, 0.2); + --bs-table-border-color: rgba(255, 193, 7, 0.3); + } + + :global([data-bs-theme="dark"]) .table-success { + --bs-table-bg: rgba(25, 135, 84, 0.2); + --bs-table-border-color: rgba(25, 135, 84, 0.3); + } diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte index 6d7d621..2ebb2c2 100644 --- a/web/src/lib/components/SpfRecordsDisplay.svelte +++ b/web/src/lib/components/SpfRecordsDisplay.svelte @@ -1,18 +1,27 @@ @@ -34,7 +43,11 @@ 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.

+

+ 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} @@ -58,25 +71,40 @@ {#if spf.all_qualifier}
All Mechanism Policy: - {#if spf.all_qualifier === '-'} + {#if spf.all_qualifier === "-"} Strict (-all) - {:else if spf.all_qualifier === '~'} + {:else if spf.all_qualifier === "~"} Softfail (~all) - {:else if spf.all_qualifier === '+'} + {:else if spf.all_qualifier === "+"} Pass (+all) - {:else if spf.all_qualifier === '?'} + {: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 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 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} @@ -84,14 +112,16 @@ {/if} {#if spf.record}
- Record:
+ Record:
{spf.record}
{/if} {#if spf.error}
- - {spf.valid ? 'Warning:' : 'Error:'} {spf.error} + + {spf.valid ? "Warning:" : "Error:"} + {spf.error}
{/if}
diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 971c1ac..518e996 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -5,36 +5,55 @@ interface TextSegment { text: string; highlight?: { - color: "good" | "warning" | "danger"; + color?: "good" | "warning" | "danger"; bold?: boolean; + emphasis?: boolean; + monospace?: boolean; }; link?: string; } interface Props { + children?: import("svelte").Snippet; report: Report; } - let { report }: Props = $props(); + let { children, report }: Props = $props(); function buildSummary(): TextSegment[] { const segments: TextSegment[] = []; // Email sender information const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; - const hasDkim = report.authentication?.dkim && report.authentication.dkim.length > 0; - const dkimPassed = hasDkim && report.authentication.dkim.some(d => d.result === "pass"); + const hasDkim = + report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0; + const dkimPassed = + report.authentication?.dkim && + report.authentication?.dkim.length > 0 && + report.authentication?.dkim?.some((d) => d.result === "pass"); segments.push({ text: "Received a " }); segments.push({ - text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", - highlight: { color: dkimPassed ? "good" : "danger", bold: true }, - link: "#authentication-dkim" + text: hasDkim ? "DKIM-signed" : "non-DKIM-signed", + highlight: { + color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger", + bold: true, + }, + link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details", }); - segments.push({ text: " email from " }); + segments.push({ text: " email" }); + if (hasDkim && !dkimPassed) { + segments.push({ text: " with " }); + segments.push({ + text: "an invalid signature", + highlight: { color: "danger", bold: true }, + link: "#authentication-dkim", + }); + } + segments.push({ text: " from " }); segments.push({ text: mailFrom, - highlight: { emphasis: true } + highlight: { emphasis: true }, }); // Server information and hops @@ -47,12 +66,12 @@ segments.push({ text: serverName, highlight: { monospace: true }, - link: "#header-details" + link: "#header-details", }); segments.push({ text: " after " }); segments.push({ - text: `${hopCount-1} hop${hopCount-1 !== 1 ? "s" : ""}`, - link: "#email-path" + text: `${hopCount - 1} hop${hopCount - 1 !== 1 ? "s" : ""}`, + link: "#email-path", }); } @@ -65,22 +84,25 @@ segments.push({ text: "authenticated", highlight: { color: "good", bold: true }, - link: "#authentication-details" + link: "#authentication-details", }); segments.push({ text: " to send email on behalf of " }); - segments.push({ text: report.header_analysis?.domain_alignment?.from_domain, highlight: {monospace: true} }); + segments.push({ + text: report.header_analysis?.domain_alignment?.from_domain || "unknown domain", + highlight: { monospace: true }, + }); } else if (spfResult && spfResult !== "none") { segments.push({ text: "not authenticated", highlight: { color: "danger", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", }); segments.push({ text: " (failed authentication checks)" }); } else { segments.push({ text: "not authenticated", highlight: { color: "warning", bold: true }, - link: "#authentication-details" + link: "#authentication-details", }); segments.push({ text: " (lacks proper authentication)" }); } @@ -92,21 +114,23 @@ segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", + }); + segments.push({ + text: ", the sending server is not authorized to send mail for this domain", }); - segments.push({ text: ", the sending server is not authorized to send mail for this domain" }); } else if (spfResult === "softfail") { segments.push({ text: "soft-failed", highlight: { color: "warning", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", }); segments.push({ text: ", the sending server may not be authorized" }); } else if (spfResult === "temperror" || spfResult === "permerror") { segments.push({ text: "encountered an error", - highlight: { color: "warning", bold: true }, - link: "#authentication-spf" + highlight: { color: "danger", bold: true }, + link: "#authentication-spf", }); segments.push({ text: ", check your SPF record configuration" }); } else if (spfResult === "none") { @@ -114,9 +138,30 @@ segments.push({ text: "no SPF record", highlight: { color: "danger", bold: true }, - link: "#dns-spf" + link: "#dns-spf", + }); + segments.push({ + text: ", you should add one to specify which servers can send email on your behalf", + }); + } + } + + // SPF DNS record check + const spfRecords = report.dns_results?.spf_records; + if (spfRecords && spfRecords.length > 0) { + const invalidSpfRecords = spfRecords.filter((r) => !r.valid && r.record); + if (invalidSpfRecords.length > 0) { + segments.push({ text: ". Your SPF record" }); + if (invalidSpfRecords.length > 1) { + segments.push({ text: "s are " }); + } else { + segments.push({ text: " is " }); + } + segments.push({ + text: "invalid", + highlight: { color: "danger", bold: true }, + link: "#dns-spf", }); - segments.push({ text: ", you should add one to specify which servers can send email on your behalf" }); } } @@ -129,13 +174,13 @@ segments.push({ text: "good", highlight: { color: "good", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); } else if (iprevResult.result === "fail") { segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); segments.push({ text: " to pass the test" }); } else { @@ -143,7 +188,7 @@ segments.push({ text: iprevResult.result, highlight: { color: "warning", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); } } @@ -152,20 +197,20 @@ const blacklists = report.blacklists; if (blacklists && Object.keys(blacklists).length > 0) { const allChecks = Object.values(blacklists).flat(); - const listedCount = allChecks.filter(check => check.listed).length; + const listedCount = allChecks.filter((check) => check.listed).length; segments.push({ text: ". Your server is " }); if (listedCount > 0) { segments.push({ text: `blacklisted on ${listedCount} list${listedCount !== 1 ? "s" : ""}`, highlight: { color: "danger", bold: true }, - link: "#rbl-details" + link: "#rbl-details", }); } else { segments.push({ text: "not blacklisted", highlight: { color: "good", bold: true }, - link: "#rbl-details" + link: "#rbl-details", }); } } @@ -178,7 +223,7 @@ segments.push({ text: "good", highlight: { color: "good", bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", }); if (!domainAlignment.aligned) { segments.push({ text: " using organizational domain" }); @@ -187,17 +232,44 @@ segments.push({ text: "misaligned", highlight: { color: "danger", bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", }); segments.push({ text: ": " }); segments.push({ text: "Return-Path", highlight: { monospace: true } }); segments.push({ text: " is set to an address of " }); - segments.push({ text: report.header_analysis?.domain_alignment?.return_path_domain, highlight: { monospace: true } }); + segments.push({ + text: + report.header_analysis?.domain_alignment?.return_path_domain || + "unknown domain", + highlight: { monospace: true }, + }); segments.push({ text: ", you should " }); segments.push({ text: "update it", highlight: { bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", + }); + } + } + + // DKIM DNS record check + const dkimRecords = report.dns_results?.dkim_records; + if (dkimRecords && Object.keys(dkimRecords).length > 0) { + const invalidDkimKeys = Object.entries(dkimRecords) + .filter(([_, record]) => !record.valid && record.record) + .map(([key, _]) => key); + + if (invalidDkimKeys.length > 0) { + segments.push({ text: ". Your DKIM record" }); + if (invalidDkimKeys.length > 1) { + segments.push({ text: "s are " }); + } else { + segments.push({ text: " is " }); + } + segments.push({ + text: "invalid", + highlight: { color: "danger", bold: true }, + link: "#dns-dkim", }); } } @@ -210,25 +282,28 @@ segments.push({ text: "don't have", highlight: { color: "danger", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: " a DMARC record, " }); - segments.push({ text: "consider adding at least a record with the '", highlight: { bold : true } }); - segments.push({ text: "none", highlight: { monospace: true, bold: true } }); - segments.push({ text: "' policy", highlight: { bold : true } }); - } else if (!dmarcRecord.valid) { - segments.push({ text: ". Your DMARC record has " }); segments.push({ - text: "issues", + text: "consider adding at least a record with the '", + highlight: { bold: true }, + }); + segments.push({ text: "none", highlight: { monospace: true, bold: true } }); + segments.push({ text: "' policy", highlight: { bold: true } }); + } else if (!dmarcRecord.valid) { + segments.push({ text: ". Your DMARC record is " }); + segments.push({ + text: "invalid", highlight: { color: "danger", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); } else if (dmarcRecord.policy === "none") { segments.push({ text: ". Your DMARC policy is " }); segments.push({ text: "set to 'none'", highlight: { color: "warning", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: ", which provides monitoring but no protection" }); } else if (dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject") { @@ -236,7 +311,7 @@ segments.push({ text: dmarcRecord.policy, highlight: { color: "good", bold: true, monospace: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: "'" }); if (dmarcRecord.policy === "reject") { @@ -247,42 +322,65 @@ segments.push({ text: "'" }); } } - } else if (dmarcResult && dmarcResult.result === "fail") { + } else if (dmarcResult === "fail") { segments.push({ text: ". DMARC check " }); segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#authentication-dmarc" + link: "#authentication-dmarc", }); } // BIMI - if (dmarcRecord.valid && dmarcRecord.policy != "none") { - const bimiResult = report.authentication?.bimi; + const bimiResult = report.authentication?.bimi; + if ( + dmarcRecord && + dmarcRecord.valid && + dmarcRecord.policy != "none" && + (!bimiResult || bimiResult.result !== "skipped") + ) { const bimiRecord = report.dns_results?.bimi_record; if (bimiRecord?.valid) { segments.push({ text: ". Your domain includes " }); segments.push({ text: "BIMI", highlight: { color: "good", bold: true }, - link: "#dns-bimi" + link: "#dns-bimi", }); - segments.push({ text: " for brand indicator display" }); - } else if (bimiResult && bimiResult.details.indexOf("(No BIMI records found)") >= 0) { + if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) { + segments.push({ text: " declined to participate" }); + } else if (bimiResult?.result === "fail") { + segments.push({ text: " but " }); + segments.push({ + text: "has issues", + highlight: { color: "danger", bold: true }, + link: "#authentication-bimi", + }); + } else { + segments.push({ text: " for brand indicator display" }); + } + } else if ( + bimiResult && + bimiResult.details && + bimiResult.details.indexOf("(No BIMI records found)") >= 0 + ) { segments.push({ text: ". Your domain has no " }); segments.push({ text: "BIMI record", highlight: { color: "warning", bold: true }, - link: "#dns-bimi" + link: "#dns-bimi", }); segments.push({ text: ", you could " }); - segments.push({ text: "add a record to decline participation", highlight: { bold: true } }); + segments.push({ + text: "add a record to decline participation", + highlight: { bold: true }, + }); } else if (bimiResult || bimiRecord) { segments.push({ text: ". Your domain has " }); segments.push({ text: "BIMI configured with issues", highlight: { color: "warning", bold: true }, - link: "#dns-bimi" + link: "#dns-bimi", }); } } @@ -293,19 +391,21 @@ segments.push({ text: ". " }); segments.push({ text: "ARC chain validation", - link: "#authentication-arc" + link: "#authentication-arc", }); segments.push({ text: " " }); if (arcResult.chain_valid) { segments.push({ text: "passed", - highlight: { color: "good", bold: true } + highlight: { color: "good", bold: true }, + }); + segments.push({ + text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding`, }); - segments.push({ text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding` }); } else { segments.push({ text: "failed", - highlight: { color: "danger", bold: true } + highlight: { color: "danger", bold: true }, }); segments.push({ text: ", which may indicate issues with email forwarding" }); } @@ -316,20 +416,36 @@ const listUnsubscribe = headers?.["list-unsubscribe"]; const listUnsubscribePost = headers?.["list-unsubscribe-post"]; - const hasNewsletterHeaders = (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) || - (listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present); + const hasNewsletterHeaders = + (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) || + (listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present); - if (!hasNewsletterHeaders && (listUnsubscribe?.importance === "newsletter" || listUnsubscribePost?.importance === "newsletter")) { + if ( + !hasNewsletterHeaders && + (listUnsubscribe?.importance === "newsletter" || + listUnsubscribePost?.importance === "newsletter") + ) { segments.push({ text: ". This email is " }); segments.push({ text: "missing unsubscribe headers", highlight: { color: "warning", bold: true }, - link: "#header-details" + link: "#header-details", }); segments.push({ text: " and is " }); segments.push({ text: "not suitable for marketing campaigns", - highlight: { bold: true } + highlight: { bold: true }, + }); + } + + // One-click unsubscribe check + const unsubscribeMethods = report.content_analysis?.unsubscribe_methods; + if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) { + segments.push({ text: ". This email could benefit from " }); + segments.push({ + text: "one-click unsubscribe", + highlight: { color: "warning", bold: true }, + link: "#content-details", }); } @@ -344,7 +460,7 @@ segments.push({ text: "flagged as spam", highlight: { color: "danger", bold: true }, - link: "#spam-details" + link: "#spam-details", }); segments.push({ text: " and needs review" }); } else if (contentScore < 50) { @@ -352,49 +468,55 @@ segments.push({ text: "needs improvement", highlight: { color: "warning", bold: true }, - link: "#content-details" + link: "#content-details", }); } else if (contentScore >= 100 && spamScore >= 100) { segments.push({ text: "Content " }); segments.push({ text: "looks great", highlight: { color: "good", bold: true }, - link: "#content-details" + link: "#content-details", }); } else if (spamScore < 50) { segments.push({ text: "Your " }); segments.push({ text: "spam score", highlight: { color: "danger", bold: true }, - link: "#spam-details" + link: "#spam-details", }); segments.push({ text: " is low" }); - if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { - segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + if (report.spamassassin?.tests?.includes("EMPTY_MESSAGE")) { + segments.push({ + text: " (you sent an empty message, which can cause this issue, retry with some real content)", + highlight: { bold: true }, + }); } } else if (spamScore < 90) { segments.push({ text: "Pay attention to your " }); segments.push({ text: "spam score", highlight: { color: "warning", bold: true }, - link: "#spam-details" + link: "#spam-details", }); - if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { - segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + if (report.spamassassin?.tests?.includes("EMPTY_MESSAGE")) { + segments.push({ + text: " (you sent an empty message, which can cause this issue, retry with some real content)", + highlight: { bold: true }, + }); } } else if (contentScore >= 80) { segments.push({ text: "Content " }); segments.push({ text: "looks good", highlight: { color: "good", bold: true }, - link: "#content-details" + link: "#content-details", }); } else { segments.push({ text: "Content " }); segments.push({ text: "should be reviewed", highlight: { color: "warning", bold: true }, - link: "#content-details" + link: "#content-details", }); } @@ -403,7 +525,7 @@ return segments; } - function getColorClass(color: "good" | "warning" | "danger"): string { + function getColorClass(color?: "good" | "warning" | "danger"): string { switch (color) { case "good": return "text-success"; @@ -411,12 +533,63 @@ return "text-warning"; case "danger": return "text-danger"; + default: + return ""; } } const summarySegments = $derived(buildSummary()); +
+
+
+ + 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?.()} +
+
+ - -
-
-
- - 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 🔽 -

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

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

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

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

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

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

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

+ + + Whitelist Checks + + Informational +

+
+
+

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

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

{status}

- - -

{getErrorTitle(status)}

- - -

{getErrorDescription(status)}

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

Looking for something specific?

- -
- {/if} -
-
+
- - diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f0031bb..92bb4db 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,27 +1,68 @@ + + + +
@@ -29,7 +70,26 @@ {@render children?.()} -

@@ -97,6 +178,27 @@ border-top: 3px solid #9332bb; } + footer a { + text-decoration: none; + } + + .footer-nav { + list-style: none; + padding: 0; + margin: 0; + gap: 0; + } + + .footer-nav li { + display: flex; + align-items: center; + } + + .footer-nav li:not(:last-child)::after { + content: "|"; + margin: 0 0.5rem; + } + .footer-links { list-style: none; padding: 0; @@ -108,7 +210,6 @@ .footer-links a { color: rgba(255, 255, 255, 0.7); - text-decoration: none; transition: color 0.3s; } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index ecfbbdd..b9259fe 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,10 +1,30 @@ + + + Blacklist Check - happyDeliver + + +
+
+
+ +
+

+ + Check IP Blacklist Status +

+

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

+
+ + +
+
+

Enter IP Address

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

+ + What's Checked +

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

+ + Why Check Blacklists? +

+

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

+

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

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

+ + Need Complete Email Analysis? +

+

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

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

+ + Blacklist Analysis +

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

Checking {ip}...

+

Querying DNS-based blacklists

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

Check Failed

+

{error}

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

+ {result.ip} +

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

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

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

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

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

+ + What This Means +

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

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

+ {:else} +

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

+
+

Recommended Actions:

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

+ + Want Complete Email Analysis? +

+

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

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

+ + Test Domain Configuration +

+

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

+
+ + +
+
+

Enter Domain Name

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

+ + What's Checked +

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

+ + Need More? +

+

+ For complete email deliverability analysis including: +

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

+ + Domain Analysis +

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

Analyzing {domain}...

+

Checking DNS records and configuration

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

Analysis Failed

+

{error}

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

+ {result.domain} +

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

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

+
+ {:else} +

Domain Configuration Score

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

+ + Want Complete Email Analysis? +

+

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

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

+ + Test History +

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

Loading tests...

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

No tests yet

+

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

+ +
+ {:else} + + + + {#if totalPages > 1} + + {/if} + {/if} +
+
+
diff --git a/web/src/routes/test/+page.ts b/web/src/routes/test/+page.ts index d2f88f2..8f8fd5b 100644 --- a/web/src/routes/test/+page.ts +++ b/web/src/routes/test/+page.ts @@ -10,10 +10,11 @@ export const load: Load = async ({}) => { try { response = await apiCreateTest(); } catch (err) { - error(err.response.status, err.message); + const errorObj = err as { response?: { status?: number }; message?: string }; + error(errorObj.response?.status || 500, errorObj.message || "Unknown error"); } - if (response.response.ok) { + 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 8ac67eb..113209d 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,25 +1,34 @@ - {report ? `Test of ${report.dns_results.from_domain} ${report.test_id.slice(0, 7)}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver + + {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 +
@@ -156,14 +216,7 @@

Loading test...

{:else if error} -
-
- -
-
+ {:else if test && test.status !== "analyzed"}
- + +
+ +
+
+ + {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} +
+
+ +
+
+ {/if} + {#if report.dns_results}
@@ -265,17 +334,45 @@ {/if} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
-
- + {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} + + {/snippet} + + + {#snippet whitelistChecks(whitelists: BlacklistRecords)} + + {/snippet} + + + {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {@render whitelistChecks(report.whitelists)}
+ {:else} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {/if} + + {#if report.whitelists && Object.keys(report.whitelists).length > 0} +
+
+ {@render whitelistChecks(report.whitelists)} +
+
+ {/if} {/if} @@ -283,26 +380,28 @@ {/if} - - {#if report.spamassassin} + + {#if report.spamassassin || report.rspamd}
-
- -
+ {#if report.spamassassin} +
+ +
+ {/if} + {#if report.rspamd} +
+ +
+ {/if}
{/if} @@ -348,23 +447,6 @@ } } - .category-section { - margin-bottom: 2rem; - } - - .category-title { - font-size: 1.25rem; - font-weight: 600; - color: #495057; - padding-bottom: 0.5rem; - border-bottom: 2px solid #e9ecef; - } - - .category-score { - font-size: 1rem; - font-weight: 700; - } - .menu-container { position: relative; } diff --git a/web/static/img/og.webp b/web/static/img/og.webp new file mode 100644 index 0000000..986dda5 Binary files /dev/null and b/web/static/img/og.webp differ diff --git a/web/static/img/report.webp b/web/static/img/report.webp new file mode 100644 index 0000000..d3df7a9 Binary files /dev/null and b/web/static/img/report.webp differ