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/Dockerfile b/Dockerfile index e731aa3..9626813 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA # Stage 1: Build the Svelte application -FROM node:22-alpine AS nodebuild +FROM node:24-alpine AS nodebuild WORKDIR /build @@ -31,19 +31,97 @@ COPY --from=nodebuild /build/web/build/ ./web/build/ RUN go generate ./... && \ CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver -# Stage 3: Runtime image with Postfix and all filters +# Stage 3: Prepare perl and spamass-milt +FROM alpine:3 AS pl + +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ + build-base \ + libmilter-dev \ + musl-obstack-dev \ + openssl \ + openssl-dev \ + perl-app-cpanminus \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@edge \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ + perl-dev \ + spamassassin-client \ + zlib-dev \ + && \ + ln -s /usr/bin/ld /bin/ld + +RUN cpanm --notest Mail::SPF && \ + cpanm --notest Mail::Milter::Authentication + +RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \ + tar xzf spamass-milter-0.4.0.tar.gz && \ + cd spamass-milter-0.4.0 && \ + ./configure && make install + +# Stage 4: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages -RUN apk add --no-cache \ +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ bash \ ca-certificates \ - opendkim \ - opendkim-utils \ - opendmarc \ + libmilter \ + openssl \ + perl \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@edge \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ postfix \ postfix-pcre \ - postfix-policyd-spf-perl \ + rspamd \ spamassassin \ spamassassin-client \ supervisor \ @@ -51,8 +129,8 @@ RUN apk add --no-cache \ tzdata \ && rm -rf /var/cache/apk/* -# Get test-only version of postfix-policyd-spf-perl -ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl +# Copy Mail::Milter::Authentication and its dependancies +COPY --from=pl /usr/local/ /usr/local/ # Create happydeliver user and group RUN addgroup -g 1000 happydeliver && \ @@ -62,12 +140,15 @@ RUN addgroup -g 1000 happydeliver && \ RUN mkdir -p /etc/happydeliver \ /var/lib/happydeliver \ /var/log/happydeliver \ - /var/spool/postfix/opendkim \ - /var/spool/postfix/opendmarc \ - /etc/opendkim/keys \ + /var/cache/authentication_milter \ + /var/lib/authentication_milter \ + /var/spool/postfix/authentication_milter \ + /var/spool/postfix/spamassassin \ + /var/spool/postfix/rspamd \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R opendkim:postfix /var/spool/postfix/opendkim \ - && chown -R opendmarc:postfix /var/spool/postfix/opendmarc + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ + && chown rspamd:mail /var/spool/postfix/rspamd \ + && chmod 750 /var/spool/postfix/rspamd # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -75,9 +156,9 @@ RUN chmod +x /usr/local/bin/happyDeliver # Copy configuration files COPY docker/postfix/ /etc/postfix/ -COPY docker/opendkim/ /etc/opendkim/ -COPY docker/opendmarc/ /etc/opendmarc/ +COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/spamassassin/ /etc/mail/spamassassin/ +COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh @@ -89,11 +170,20 @@ 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 # 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 c76e248..3c213cd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,32 @@ -# happyDeliver +# happyDeliver - Email Deliverability Tester + +![banner](banner.webp) An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring. ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration -- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers +- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings +![A sample deliverability report](web/static/img/report.webp) + ## Quick Start ### With Docker (Recommended) -The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application. +The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application. #### What's included in the Docker container: - **Postfix MTA**: Receives emails on port 25 -- **OpenDKIM**: DKIM signature verification -- **OpenDMARC**: DMARC policy validation +- **authentication_milter**: Entreprise grade email authentication - **SpamAssassin**: Spam scoring and analysis +- **rspamd**: Second spam filter for cross-validated scoring - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports @@ -34,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 @@ -60,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 @@ -85,7 +163,7 @@ 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: @@ -106,9 +184,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi ``` # Transport map - route test emails to happyDeliver LMTP server - # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 + /^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 ``` 3. Append the created file to `transport_maps` in your `main.cf`: @@ -142,7 +220,7 @@ Response: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "test-550e8400@localhost", + "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost", "status": "pending", "message": "Send your test email to the address above" } @@ -186,20 +264,14 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com ## Scoring System -The deliverability score is calculated from 0 to 10 based on: +The deliverability score is calculated from A to F based on: -- **Authentication (3 pts)**: SPF, DKIM, DMARC validation -- **Spam (2 pts)**: SpamAssassin score -- **Blacklist (2 pts)**: RBL/DNSBL checks -- **Content (2 pts)**: HTML quality, links, images, unsubscribe -- **Headers (1 pt)**: Required headers, MIME structure - -**Ratings:** -- 9-10: Excellent -- 7-8.9: Good -- 5-6.9: Fair -- 3-4.9: Poor -- 0-2.9: Critical +- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records +- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation +- **Blacklist**: RBL/DNSBL checks +- **Headers**: Required headers, MIME structure, Domain alignment +- **Spam**: SpamAssassin and rspamd scores (combined 50/50) +- **Content**: HTML quality, links, images, unsubscribe ## Funding diff --git a/api/openapi.yaml b/api/openapi.yaml index 467f62c..e989261 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -52,7 +52,7 @@ paths: tags: - tests summary: Get test status - description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available. + description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -60,7 +60,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Test status retrieved successfully @@ -88,7 +89,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Report retrieved successfully @@ -116,7 +118,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Raw email retrieved successfully @@ -131,6 +134,107 @@ paths: schema: $ref: '#/components/schemas/Error' + /report/{id}/reanalyze: + post: + tags: + - reports + summary: Reanalyze email and regenerate report + description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes. + operationId: reanalyzeReport + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) + responses: + '200': + description: Report regenerated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '404': + description: Email not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error during reanalysis + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /domain: + post: + tags: + - tests + summary: Test a domain's email configuration + description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately. + operationId: testDomain + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestRequest' + responses: + '200': + description: Domain test completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DomainTestResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /blacklist: + post: + tags: + - tests + summary: Check an IP address against DNS blacklists + description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately. + operationId: checkBlacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BlacklistCheckRequest' + responses: + '200': + description: Blacklist check completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BlacklistCheckResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: tags: @@ -154,31 +258,22 @@ components: - id - email - status - - created_at properties: id: type: string - format: uuid - description: Unique test identifier - example: "550e8400-e29b-41d4-a716-446655440000" + 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-550e8400@example.com" + 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" - created_at: - type: string - format: date-time - description: Test creation timestamp - updated_at: - type: string - format: date-time - description: Last update timestamp TestResponse: type: object @@ -189,12 +284,13 @@ components: properties: id: type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" + 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-550e8400@example.com" + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" status: type: string enum: [pending] @@ -209,42 +305,68 @@ components: - id - test_id - score - - checks + - grade - created_at properties: id: type: string - format: uuid - description: Report identifier + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) test_id: type: string - format: uuid - description: Associated test ID + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) score: - type: number - format: float + type: integer minimum: 0 - maximum: 10 - description: Overall deliverability score (0-10) - example: 8.5 + 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' - checks: - type: array - items: - $ref: '#/components/schemas/Check' authentication: $ref: '#/components/schemas/AuthenticationResults' spamassassin: $ref: '#/components/schemas/SpamAssassinResult' - dns_records: - type: array - items: - $ref: '#/components/schemas/DNSRecord' + rspamd: + $ref: '#/components/schemas/RspamdResult' + dns_results: + $ref: '#/components/schemas/DNSResults' blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' + 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 @@ -255,92 +377,386 @@ components: ScoreSummary: type: object required: + - dns_score + - dns_grade - authentication_score + - authentication_grade - spam_score + - spam_grade - blacklist_score - - content_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: number - format: float + type: integer minimum: 0 - maximum: 3 - description: SPF/DKIM/DMARC score (max 3 pts) - example: 2.8 + 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: number - format: float + type: integer minimum: 0 - maximum: 2 - description: SpamAssassin score (max 2 pts) - example: 1.5 + 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: number - format: float + type: integer minimum: 0 - maximum: 2 - description: Blacklist check score (max 2 pts) - example: 2.0 - content_score: - type: number - format: float - minimum: 0 - maximum: 2 - description: Content quality score (max 2 pts) - example: 1.8 + 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 - minimum: 0 - maximum: 1 - description: Header quality score (max 1 pt) - example: 0.9 + 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"] - Check: + ContentIssue: type: object required: - - category - - name - - status - - score + - type + - severity - message properties: - category: + type: type: string - enum: [authentication, dns, content, blacklist, headers, spam] - description: Check category - example: "authentication" - name: - type: string - description: Check name - example: "DKIM Signature" - status: - type: string - enum: [pass, fail, warn, info, error] - description: Check result status - example: "pass" - score: - type: number - format: float - description: Points contributed to total score - example: 1.0 - message: - type: string - description: Human-readable result message - example: "DKIM signature is valid" - details: - type: string - description: Additional details (may be JSON) + 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: "info" + 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: Remediation advice - example: "Your DKIM configuration is correct" + 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 @@ -353,6 +769,18 @@ components: $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 @@ -361,7 +789,7 @@ components: properties: result: type: string - enum: [pass, fail, none, neutral, softfail, temperror, permerror] + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] description: Authentication result example: "pass" domain: @@ -376,13 +804,75 @@ components: 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 @@ -403,47 +893,342 @@ components: 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 - DNSRecord: + SpamTestDetail: type: object required: - - domain - - record_type - - status + - 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%" + + 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/RspamdSymbol' + 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) + + RspamdSymbol: + type: object + required: + - name + - score + properties: + name: + type: string + description: Symbol name + example: "BAYES_HAM" + score: + type: number + format: float + description: Score contribution of this symbol + example: -1.9 + params: + type: string + description: Symbol parameters or options + example: "0.02" + + 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: + record: type: string - enum: [MX, SPF, DKIM, DMARC] - description: DNS record type - example: "SPF" - status: + 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 - enum: [found, missing, invalid] - description: Record status - example: "found" - value: + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: type: string - description: Record value - example: "v=spf1 include:_spf.example.com ~all" + 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: - - ip - rbl - listed properties: - ip: - type: string - description: IP address checked - example: "192.0.2.1" rbl: type: string description: RBL/DNSBL name @@ -456,6 +1241,9 @@ components: type: string description: RBL response code or message example: "127.0.0.2" + error: + type: string + description: RBL error if any Status: type: object @@ -505,3 +1293,90 @@ components: 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) diff --git a/banner.webp b/banner.webp new file mode 100644 index 0000000..8ed7da1 Binary files /dev/null and b/banner.webp differ 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 4ba64c0..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 @@ -23,18 +23,9 @@ services: - ./data:/var/lib/happydeliver # Log files - ./logs:/var/log/happydeliver - # Optional: Override config - # - ./custom-config.yaml:/etc/happydeliver/config.yaml 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..3769365 100644 --- a/docker/README.md +++ b/docker/README.md @@ -109,12 +109,13 @@ 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) + +Note that the hostname of the container is used to filter the authentication tests results. Example: ```bash -docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ... +docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... ``` ## Volumes diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json new file mode 100644 index 0000000..5db3bbc --- /dev/null +++ b/docker/authentication_milter/authentication_milter.json @@ -0,0 +1,75 @@ +{ + "logtoerr" : "1", + "error_log" : "", + "connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock", + "umask" : "0007", + "runas" : "mail", + "rungroup" : "mail", + "authserv_id" : "__HOSTNAME__", + + "connect_timeout" : 30, + "command_timeout" : 30, + "content_timeout" : 300, + "dns_timeout" : 10, + "dns_retry" : 2, + + "handlers" : { + + "Sanitize" : { + "hosts_to_remove" : [ + "__HOSTNAME__" + ], + "extra_auth_results_types" : [ + "X-Spam-Status", + "X-Spam-Report", + "X-Spam-Level", + "X-Spam-Checker-Version" + ] + }, + + "SPF" : { + "hide_none" : 0 + }, + + "DKIM" : { + "hide_none" : 0, + }, + + "XGoogleDKIM" : { + "hide_none" : 1, + }, + + "ARC" : { + "hide_none" : 0, + }, + + "DMARC" : { + "hide_none" : 0, + "detect_list_id" : "1" + }, + + "BIMI" : {}, + + "PTR" : {}, + + "SenderID" : { + "hide_none" : 1 + }, + + "IPRev" : {}, + + "Auth" : {}, + + "AlignedFrom" : {}, + + "LocalIP" : {}, + + "TrustedIP" : { + "trusted_ip_list" : [] + }, + + "!AddID" : {}, + + "ReturnOK" : {} + } +} diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini new file mode 100644 index 0000000..8097ac6 --- /dev/null +++ b/docker/authentication_milter/mail-dmarc.ini @@ -0,0 +1,58 @@ +; This is YOU. DMARC reports include information about the reports. Enter it here. +[organization] +domain = example.com +org_name = My Company Limited +email = admin@example.com +extra_contact_info = http://example.com + +; aggregate DMARC reports need to be stored somewhere. Any database +; with a DBI module (MySQL, SQLite, DBD, etc.) should work. +; SQLite and MySQL are tested. +; Default is sqlite. +[report_store] +backend = SQL +;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite +dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306 +user = authmilterusername +pass = authmiltpassword + +; backend can be perl or libopendmarc +[dmarc] +backend = perl + +[dns] +timeout = 5 +public_suffix_list = share/public_suffix_list + +[smtp] +; hostname is the external FQDN of this MTA +hostname = mx1.example.com +cc = dmarc.copy@example.com + +; list IP addresses to whitelist (bypass DMARC reject/quarantine) +; see sample whitelist in share/dmarc_whitelist +whitelist = /path/to/etc/dmarc_whitelist + +; By default, we attempt to email directly to the report recipient. +; Set these to relay via a SMTP smart host. +smarthost = mx2.example.com +smartuser = dmarccopyusername +smartpass = dmarccopypassword + +[imap] +server = mail.example.com +user = +pass = +; the imap folder where new dmarc messages will be found +folder = dmarc +; the folders to store processed reports (a=aggregate, f=forensic) +f_done = dmarc.forensic +a_done = dmarc.aggregate + +[http] +port = 8080 + +[https] +port = 8443 +ssl_crt = +ssl_key = \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 445602d..ef45b61 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,34 +4,42 @@ set -e echo "Starting happyDeliver container..." # Get environment variables with defaults -HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" +[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" echo "Domain: $HAPPYDELIVER_DOMAIN" -# Create runtime directories -mkdir -p /var/run/opendkim /var/run/opendmarc -chown opendkim:postfix /var/run/opendkim -chown opendmarc:postfix /var/run/opendmarc - # Create socket directories -mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc -chown opendkim:postfix /var/spool/postfix/opendkim -chown opendmarc:postfix /var/spool/postfix/opendmarc -chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc +mkdir -p /var/spool/postfix/authentication_milter +chown mail:mail /var/spool/postfix/authentication_milter +chmod 750 /var/spool/postfix/authentication_milter + +mkdir -p /var/spool/postfix/rspamd +chown rspamd:mail /var/spool/postfix/rspamd +chmod 750 /var/spool/postfix/rspamd # Create log directory -mkdir -p /var/log/happydeliver +mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter chown happydeliver:happydeliver /var/log/happydeliver +chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter # Replace placeholders in Postfix configuration echo "Configuring Postfix..." sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf -# Replace placeholders in OpenDMARC configuration -sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf +# Add certificates to postfix +[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && { + cat <> /etc/postfix/main.cf +smtpd_tls_cert_file = ${POSTFIX_CERT_FILE} +smtpd_tls_key_file = ${POSTFIX_KEY_FILE} +smtpd_tls_security_level = may +EOF +} + +# Replace placeholders in configurations +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json # Initialize Postfix aliases if [ -f /etc/postfix/aliases ]; then diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf deleted file mode 100644 index 8fe2f8c..0000000 --- a/docker/opendkim/opendkim.conf +++ /dev/null @@ -1,39 +0,0 @@ -# OpenDKIM configuration for happyDeliver -# Verifies DKIM signatures on incoming emails - -# Log to syslog -Syslog yes -SyslogSuccess yes -LogWhy yes - -# Run as this user and group -UserID opendkim:mail - -UMask 002 - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendkim/opendkim.sock - -# Process ID file -PidFile /var/run/opendkim/opendkim.pid - -# Operating mode - verify only (not signing) -Mode v - -# Canonicalization methods -Canonicalization relaxed/simple - -# DNS timeout -DNSTimeout 5 - -# Add header for verification results -AlwaysAddARHeader yes - -# Accept unsigned mail -On-NoSignature accept - -# Always add Authentication-Results header -AlwaysAddARHeader yes - -# Maximum verification attempts -MaximumSignaturesToVerify 3 diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf deleted file mode 100644 index 882e11c..0000000 --- a/docker/opendmarc/opendmarc.conf +++ /dev/null @@ -1,41 +0,0 @@ -# OpenDMARC configuration for happyDeliver -# Verifies DMARC policies on incoming emails - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock - -# Process ID file -PidFile /var/run/opendmarc/opendmarc.pid - -# Run as this user and group -UserID opendmarc:mail - -UMask 002 - -# Syslog configuration -Syslog true -SyslogFacility mail - -# Ignore authentication results from other hosts -IgnoreAuthenticatedClients true - -# Accept mail even if DMARC fails (we're analyzing, not filtering) -RejectFailures false - -# Trust Authentication-Results headers from localhost only -TrustedAuthservIDs __HOSTNAME__ - -# Add DMARC results to Authentication-Results header -#AddAuthenticationResults true - -# DNS timeout -DNSTimeout 5 - -# History file (for reporting) -# HistoryFile /var/spool/opendmarc/opendmarc.dat - -# Ignore hosts file -# IgnoreHosts /etc/opendmarc/ignore.hosts - -# Public suffix list -# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 913eb57..5a73fb3 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -10,7 +10,7 @@ inet_interfaces = all inet_protocols = ipv4 # Recipient settings -mydestination = $myhostname, localhost.$mydomain, localhost +mydestination = localhost.$mydomain, localhost mynetworks = 127.0.0.0/8 [::1]/128 # Relay settings - accept mail for our test domain @@ -28,14 +28,13 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking smtpd_recipient_restrictions = permit_mynetworks, - reject_unauth_destination, - check_policy_service unix:private/policy-spf + reject_unauth_destination # Logging debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 92976a4..9c2ac57 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -2,7 +2,6 @@ # SMTP service smtp inet n - n - - smtpd - -o content_filter=spamassassin # Pickup service pickup unix n - n 60 1 pickup @@ -74,10 +73,6 @@ scache unix - - n - 1 scache maildrop unix - n n - - pipe flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} -# SPF policy service -policy-spf unix - n n - 0 spawn - user=nobody argv=/usr/bin/postfix-policyd-spf-perl - # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index 49fdb98..cc1deed 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ # Transport map - route test emails to happyDeliver LMTP server -# Pattern: test-@domain.com -> LMTP on localhost:2525 +# Pattern: test-@domain.com -> LMTP on localhost:2525 -/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 +/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf new file mode 100644 index 0000000..f3ed60c --- /dev/null +++ b/docker/rspamd/local.d/actions.conf @@ -0,0 +1,5 @@ +no_action = 0; +reject = null; +add_header = null; +rewrite_subject = null; +greylist = null; \ No newline at end of file diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf new file mode 100644 index 0000000..378b8a3 --- /dev/null +++ b/docker/rspamd/local.d/milter_headers.conf @@ -0,0 +1,5 @@ +# Add "extended Rspamd headers" +extended_spam_headers = true; + +skip_local = false; +skip_authenticated = false; \ No newline at end of file diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc new file mode 100644 index 0000000..485d0c9 --- /dev/null +++ b/docker/rspamd/local.d/options.inc @@ -0,0 +1,3 @@ +# rspamd options for happyDeliver +# Disable Bayes learning to keep the setup stateless +use_redis = false; diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc new file mode 100644 index 0000000..04c9a1d --- /dev/null +++ b/docker/rspamd/local.d/worker-proxy.inc @@ -0,0 +1,6 @@ +# Enable rspamd milter proxy worker via Unix socket for Postfix integration +bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail"; +upstream "local" { + default = yes; + self_scan = yes; +} diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index c248ef6..ce9a31c 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,3 +48,14 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 + +# Disable Validity network rules +dns_query_restriction deny sa-trusted.bondedsender.org +dns_query_restriction deny sa-accredit.habeas.com +dns_query_restriction deny bl.score.senderscore.com +score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 +score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 +score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 +score RCVD_IN_VALIDITY_CERTIFIED 0 +score RCVD_IN_VALIDITY_RPBL 0 +score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 1a0666e..74f1810 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -22,27 +22,26 @@ autostart=true autorestart=true priority=9 -# OpenDKIM service -[program:opendkim] -command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf +# Authentication Milter service +[program:authentication_milter] +command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid autostart=true autorestart=true priority=10 -stdout_logfile=/var/log/happydeliver/opendkim.log -stderr_logfile=/var/log/happydeliver/opendkim_error.log -user=opendkim +stdout_logfile=/var/log/happydeliver/authentication_milter.log +stderr_logfile=/var/log/happydeliver/authentication_milter.log +user=mail group=mail -# OpenDMARC service -[program:opendmarc] -command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf +# rspamd spam filter +[program:rspamd] +command=/usr/bin/rspamd -f -u rspamd -g mail autostart=true autorestart=true priority=11 -stdout_logfile=/var/log/happydeliver/opendmarc.log -stderr_logfile=/var/log/happydeliver/opendmarc_error.log -user=opendmarc -group=mail +stdout_logfile=/var/log/happydeliver/rspamd.log +stderr_logfile=/var/log/happydeliver/rspamd_error.log +user=root # SpamAssassin daemon [program:spamd] @@ -54,6 +53,18 @@ stdout_logfile=/var/log/happydeliver/spamd.log stderr_logfile=/var/log/happydeliver/spamd_error.log user=root +# SpamAssassin milter +[program:spamass_milter] +command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m +autostart=true +autorestart=true +priority=7 +stdout_logfile=/var/log/happydeliver/spamass-milter.log +stderr_logfile=/var/log/happydeliver/spamass-milter_error.log +user=mail +group=mail +umask=007 + # Postfix service [program:postfix] command=/usr/sbin/postfix start-fg diff --git a/go.mod b/go.mod index e51b1d5..d44d5cc 100644 --- a/go.mod +++ b/go.mod @@ -3,35 +3,40 @@ module git.happydns.org/happyDeliver go 1.24.6 require ( + github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.45.0 + golang.org/x/net v0.50.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/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/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/getkin/kin-openapi v0.132.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -39,33 +44,35 @@ 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/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.1 // 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.17.2 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.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 + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 939e263..717c4ff 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,21 @@ -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/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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -10,6 +24,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE 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/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= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -20,33 +36,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.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= 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/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -72,8 +90,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= @@ -84,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -95,12 +114,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= @@ -111,8 +130,8 @@ 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/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ= 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= @@ -137,10 +156,12 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX 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/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.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -149,37 +170,42 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.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= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= 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= @@ -187,13 +213,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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -209,21 +235,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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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= @@ -236,8 +262,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= @@ -259,5 +285,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/analyzer/authentication.go b/internal/analyzer/authentication.go deleted file mode 100644 index 45df0a3..0000000 --- a/internal/analyzer/authentication.go +++ /dev/null @@ -1,511 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "regexp" - "strings" - - "git.happydns.org/happyDeliver/internal/api" -) - -// AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct{} - -// NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{} -} - -// AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { - results := &api.AuthenticationResults{} - - // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults() - for _, header := range authHeaders { - a.parseAuthenticationResultsHeader(header, results) - } - - // If no Authentication-Results headers, try to parse legacy headers - if results.Spf == nil { - results.Spf = a.parseLegacySPF(email) - } - - if results.Dkim == nil || len(*results.Dkim) == 0 { - dkimResults := a.parseLegacyDKIM(email) - if len(dkimResults) > 0 { - results.Dkim = &dkimResults - } - } - - return results -} - -// 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) { - // Split by semicolon to get individual results - parts := strings.Split(header, ";") - if len(parts) < 2 { - return - } - - // Skip the authserv-id (first part) - for i := 1; i < len(parts); i++ { - part := strings.TrimSpace(parts[i]) - if part == "" { - continue - } - - // Parse SPF - if strings.HasPrefix(part, "spf=") { - if results.Spf == nil { - results.Spf = a.parseSPFResult(part) - } - } - - // Parse DKIM - if strings.HasPrefix(part, "dkim=") { - dkimResult := a.parseDKIMResult(part) - if dkimResult != nil { - if results.Dkim == nil { - dkimList := []api.AuthResult{*dkimResult} - results.Dkim = &dkimList - } else { - *results.Dkim = append(*results.Dkim, *dkimResult) - } - } - } - - // Parse DMARC - if strings.HasPrefix(part, "dmarc=") { - if results.Dmarc == nil { - results.Dmarc = a.parseDMARCResult(part) - } - } - } -} - -// parseSPFResult parses SPF result from Authentication-Results -// Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`spf=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain - domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - email := matches[1] - // Extract domain from email - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseDKIMResult parses DKIM result from Authentication-Results -// Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - // Extract details - if idx := strings.Index(part, "("); idx != -1 { - endIdx := strings.Index(part[idx:], ")") - if endIdx != -1 { - details := strings.TrimSpace(part[idx+1 : idx+endIdx]) - result.Details = &details - } - } - - return result -} - -// parseDMARCResult parses DMARC result from Authentication-Results -// Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dmarc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.from) - domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract details (action, policy, etc.) - var detailsParts []string - actionRe := regexp.MustCompile(`action=([^\s;]+)`) - if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { - detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, " ") - result.Details = &details - } - - return result -} - -// parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { - receivedSPF := email.Header.Get("Received-SPF") - if receivedSPF == "" { - return nil - } - - result := &api.AuthResult{} - - // Extract result (first word) - parts := strings.Fields(receivedSPF) - if len(parts) > 0 { - resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) - } - - // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - email := matches[1] - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - return result -} - -// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header -func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { - var results []api.AuthResult - - // Get all DKIM-Signature headers - dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] - for _, dkimHeader := range dkimHeaders { - result := api.AuthResult{ - Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone - } - - // Extract domain (d=) - domainRe := regexp.MustCompile(`d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (s=) - selectorRe := regexp.MustCompile(`s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - details := "DKIM signature present (verification status unknown)" - result.Details = &details - - results = append(results, result) - } - - return results -} - -// textprotoCanonical converts a header name to canonical form -func textprotoCanonical(s string) string { - // Simple implementation - capitalize each word - words := strings.Split(s, "-") - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) - } - } - return strings.Join(words, "-") -} - -// GetAuthenticationScore calculates the authentication score (0-3 points) -func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { - var score float32 = 0.0 - - // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 1.0 - case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 0.5 - } - } - - // DKIM: 1 point for at least one pass - if results.Dkim != nil && len(*results.Dkim) > 0 { - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - score += 1.0 - break - } - } - } - - // DMARC: 1 point for pass - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 1.0 - } - } - - // Cap at 3 points maximum - if score > 3.0 { - score = 3.0 - } - - return score -} - -// GenerateAuthenticationChecks generates check results for authentication -func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { - var checks []api.Check - - // SPF check - if results.Spf != nil { - check := a.generateSPFCheck(results.Spf) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "SPF Record", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No SPF authentication result found", - Severity: api.PtrTo(api.Medium), - Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), - }) - } - - // DKIM check - if results.Dkim != nil && len(*results.Dkim) > 0 { - for i, dkim := range *results.Dkim { - check := a.generateDKIMCheck(&dkim, i) - checks = append(checks, check) - } - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DKIM Signature", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DKIM signature found", - Severity: api.PtrTo(api.Medium), - Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), - }) - } - - // DMARC check - if results.Dmarc != nil { - check := a.generateDMARCCheck(results.Dmarc) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DMARC authentication result found", - Severity: api.PtrTo(api.Medium), - Advice: api.PtrTo("Implement DMARC policy for your domain"), - }) - } - - return checks -} - -func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "SPF Record", - } - - switch spf.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your SPF record is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "SPF validation failed" - check.Severity = api.PtrTo(api.Critical) - check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") - case api.AuthResultResultSoftfail: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation softfail" - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Review your SPF record configuration") - case api.AuthResultResultNeutral: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation neutral" - check.Severity = api.PtrTo(api.Low) - check.Advice = api.PtrTo("Consider tightening your SPF policy") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Review your SPF record configuration") - } - - if spf.Domain != nil { - details := fmt.Sprintf("Domain: %s", *spf.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: fmt.Sprintf("DKIM Signature #%d", index+1), - } - - switch dkim.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DKIM signature is valid" - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your DKIM signature is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DKIM signature validation failed" - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") - } - - var detailsParts []string - if dkim.Domain != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) - } - if dkim.Selector != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) - } - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - } - - switch dmarc.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DMARC validation passed" - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your DMARC policy is properly aligned") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DMARC validation failed" - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Configure DMARC policy for your domain") - } - - if dmarc.Domain != nil { - details := fmt.Sprintf("Domain: %s", *dmarc.Domain) - check.Details = &details - } - - return check -} diff --git a/internal/analyzer/content.go b/internal/analyzer/content.go deleted file mode 100644 index bad38c9..0000000 --- a/internal/analyzer/content.go +++ /dev/null @@ -1,830 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "strings" - "time" - "unicode" - - "git.happydns.org/happyDeliver/internal/api" - "golang.org/x/net/html" -) - -// ContentAnalyzer analyzes email content (HTML, links, images) -type ContentAnalyzer struct { - Timeout time.Duration - httpClient *http.Client -} - -// NewContentAnalyzer creates a new content analyzer with configurable timeout -func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { - if timeout == 0 { - timeout = 10 * time.Second // Default timeout - } - return &ContentAnalyzer{ - Timeout: timeout, - httpClient: &http.Client{ - Timeout: timeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Allow up to 10 redirects - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - }, - }, - } -} - -// ContentResults represents content analysis results -type ContentResults struct { - HTMLValid bool - HTMLErrors []string - Links []LinkCheck - Images []ImageCheck - HasUnsubscribe bool - UnsubscribeLinks []string - TextContent string - HTMLContent string - TextPlainRatio float32 // Ratio of plain text to HTML consistency - ImageTextRatio float32 // Ratio of images to text - SuspiciousURLs []string - ContentIssues []string -} - -// LinkCheck represents a link validation result -type LinkCheck struct { - URL string - Valid bool - Status int - Error string - IsSafe bool - Warning string -} - -// ImageCheck represents an image validation result -type ImageCheck struct { - Src string - HasAlt bool - AltText string - Valid bool - Error string - IsBroken bool -} - -// AnalyzeContent performs content analysis on email message -func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { - results := &ContentResults{} - - // Get HTML and text parts - htmlParts := email.GetHTMLParts() - textParts := email.GetTextParts() - - // Analyze HTML parts - if len(htmlParts) > 0 { - for _, part := range htmlParts { - c.analyzeHTML(part.Content, results) - } - } - - // Analyze text parts - if len(textParts) > 0 { - for _, part := range textParts { - results.TextContent += part.Content - } - } - - // Check plain text/HTML consistency - if len(htmlParts) > 0 && len(textParts) > 0 { - results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) - } - - return results -} - -// analyzeHTML parses and analyzes HTML content -func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { - results.HTMLContent = htmlContent - - // Parse HTML - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - results.HTMLValid = false - results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err)) - return - } - - results.HTMLValid = true - - // Traverse HTML tree - c.traverseHTML(doc, results) - - // Calculate image-to-text ratio - if results.HTMLContent != "" { - textLength := len(c.extractTextFromHTML(htmlContent)) - imageCount := len(results.Images) - if textLength > 0 { - results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars - } - } -} - -// traverseHTML recursively traverses HTML nodes -func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { - if n.Type == html.ElementNode { - switch n.Data { - case "a": - // Extract and validate links - href := c.getAttr(n, "href") - if href != "" { - // Check for unsubscribe links - if c.isUnsubscribeLink(href, n) { - results.HasUnsubscribe = true - results.UnsubscribeLinks = append(results.UnsubscribeLinks, href) - } - - // Validate link - linkCheck := c.validateLink(href) - results.Links = append(results.Links, linkCheck) - - // Check for suspicious URLs - if !linkCheck.IsSafe { - results.SuspiciousURLs = append(results.SuspiciousURLs, href) - } - } - - case "img": - // Extract and validate images - src := c.getAttr(n, "src") - alt := c.getAttr(n, "alt") - - imageCheck := ImageCheck{ - Src: src, - HasAlt: alt != "", - AltText: alt, - Valid: src != "", - } - - if src == "" { - imageCheck.Error = "Image missing src attribute" - } - - results.Images = append(results.Images, imageCheck) - } - } - - // Traverse children - for child := n.FirstChild; child != nil; child = child.NextSibling { - c.traverseHTML(child, results) - } -} - -// getAttr gets an attribute value from an HTML node -func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string { - for _, attr := range n.Attr { - if attr.Key == key { - return attr.Val - } - } - return "" -} - -// isUnsubscribeLink checks if a link is an unsubscribe link -func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool { - // Check href for unsubscribe keywords - lowerHref := strings.ToLower(href) - unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"} - for _, keyword := range unsubKeywords { - if strings.Contains(lowerHref, keyword) { - return true - } - } - - // Check link text for unsubscribe keywords - text := c.getNodeText(node) - lowerText := strings.ToLower(text) - for _, keyword := range unsubKeywords { - if strings.Contains(lowerText, keyword) { - return true - } - } - - return false -} - -// getNodeText extracts text content from a node -func (c *ContentAnalyzer) getNodeText(n *html.Node) string { - if n.Type == html.TextNode { - return n.Data - } - var text string - for child := n.FirstChild; child != nil; child = child.NextSibling { - text += c.getNodeText(child) - } - return text -} - -// validateLink validates a URL and checks if it's accessible -func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck { - check := LinkCheck{ - URL: urlStr, - IsSafe: true, - } - - // Parse URL - parsedURL, err := url.Parse(urlStr) - if err != nil { - check.Valid = false - check.Error = fmt.Sprintf("Invalid URL: %v", err) - return check - } - - // Check URL safety - if c.isSuspiciousURL(urlStr, parsedURL) { - check.IsSafe = false - check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)" - } - - // Only check HTTP/HTTPS links - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - check.Valid = true - return check - } - - // Check if link is accessible (with timeout) - ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil) - if err != nil { - check.Valid = false - check.Error = fmt.Sprintf("Failed to create request: %v", err) - return check - } - - // Set a reasonable user agent - req.Header.Set("User-Agent", "HappyDeliver/1.0 (Email Deliverability Tester)") - - resp, err := c.httpClient.Do(req) - if err != nil { - // Don't fail on timeout/connection errors for external links - // Just mark as warning - check.Valid = true - check.Status = 0 - check.Warning = fmt.Sprintf("Could not verify link: %v", err) - return check - } - defer resp.Body.Close() - - check.Status = resp.StatusCode - check.Valid = true - - // Check for error status codes - if resp.StatusCode >= 400 { - check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode) - } - - return check -} - -// isSuspiciousURL checks if a URL looks suspicious -func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool { - // Check for IP address instead of domain - if c.isIPAddress(parsedURL.Host) { - return true - } - - // Check for URL shorteners (common ones) - shorteners := []string{ - "bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co", - "buff.ly", "is.gd", "bl.ink", "short.io", - } - for _, shortener := range shorteners { - if strings.Contains(strings.ToLower(parsedURL.Host), shortener) { - return true - } - } - - // Check for excessive subdomains (possible obfuscation) - parts := strings.Split(parsedURL.Host, ".") - if len(parts) > 4 { - return true - } - - // Check for URL obfuscation techniques - if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing) - return true - } - - // Check for suspicious characters in domain - if strings.ContainsAny(parsedURL.Host, "[]()<>") { - return true - } - - return false -} - -// isIPAddress checks if a string is an IP address -func (c *ContentAnalyzer) isIPAddress(host string) bool { - // Remove port if present - if idx := strings.LastIndex(host, ":"); idx != -1 { - host = host[:idx] - } - - // Simple check for IPv4 - parts := strings.Split(host, ".") - if len(parts) == 4 { - for _, part := range parts { - // Check if all characters are digits - for _, ch := range part { - if !unicode.IsDigit(ch) { - return false - } - } - } - return true - } - - // Check for IPv6 (contains colons) - if strings.Contains(host, ":") { - return true - } - - return false -} - -// extractTextFromHTML extracts plain text from HTML -func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return "" - } - - var text strings.Builder - var extract func(*html.Node) - extract = func(n *html.Node) { - if n.Type == html.TextNode { - text.WriteString(n.Data) - } - // Skip script and style tags - if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") { - return - } - for child := n.FirstChild; child != nil; child = child.NextSibling { - extract(child) - } - } - extract(doc) - - return text.String() -} - -// calculateTextPlainConsistency compares plain text and HTML versions -func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 { - // Extract text from HTML - htmlPlainText := c.extractTextFromHTML(htmlText) - - // Normalize both texts - plainNorm := c.normalizeText(plainText) - htmlNorm := c.normalizeText(htmlPlainText) - - // Calculate similarity using simple word overlap - plainWords := strings.Fields(plainNorm) - htmlWords := strings.Fields(htmlNorm) - - if len(plainWords) == 0 || len(htmlWords) == 0 { - return 0.0 - } - - // Count common words - commonWords := 0 - plainWordSet := make(map[string]bool) - for _, word := range plainWords { - plainWordSet[word] = true - } - - for _, word := range htmlWords { - if plainWordSet[word] { - commonWords++ - } - } - - // Calculate ratio (Jaccard similarity approximation) - maxWords := len(plainWords) - if len(htmlWords) > maxWords { - maxWords = len(htmlWords) - } - - if maxWords == 0 { - return 0.0 - } - - return float32(commonWords) / float32(maxWords) -} - -// normalizeText normalizes text for comparison -func (c *ContentAnalyzer) normalizeText(text string) string { - // Convert to lowercase - text = strings.ToLower(text) - - // Remove extra whitespace - text = strings.TrimSpace(text) - text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ") - - return text -} - -// GenerateContentChecks generates check results for content analysis -func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // HTML validity check - checks = append(checks, c.generateHTMLValidityCheck(results)) - - // Link checks - checks = append(checks, c.generateLinkChecks(results)...) - - // Image checks - checks = append(checks, c.generateImageChecks(results)...) - - // Unsubscribe link check - checks = append(checks, c.generateUnsubscribeCheck(results)) - - // Text/HTML consistency check - if results.TextContent != "" && results.HTMLContent != "" { - checks = append(checks, c.generateTextConsistencyCheck(results)) - } - - // Image-to-text ratio check - if len(results.Images) > 0 && results.HTMLContent != "" { - checks = append(checks, c.generateImageRatioCheck(results)) - } - - // Suspicious URLs check - if len(results.SuspiciousURLs) > 0 { - checks = append(checks, c.generateSuspiciousURLCheck(results)) - } - - return checks -} - -// generateHTMLValidityCheck creates a check for HTML validity -func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "HTML Structure", - } - - if !results.HTMLValid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) - check.Message = "HTML structure is invalid" - if len(results.HTMLErrors) > 0 { - details := strings.Join(results.HTMLErrors, "; ") - check.Details = &details - } - check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering") - } else { - check.Status = api.CheckStatusPass - check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) - check.Message = "HTML structure is valid" - check.Advice = api.PtrTo("Your HTML is well-formed") - } - - return check -} - -// generateLinkChecks creates checks for links -func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check { - var checks []api.Check - - if len(results.Links) == 0 { - return checks - } - - // Count broken links - brokenLinks := 0 - warningLinks := 0 - for _, link := range results.Links { - if link.Status >= 400 { - brokenLinks++ - } else if link.Warning != "" { - warningLinks++ - } - } - - check := api.Check{ - Category: api.Content, - Name: "Links", - } - - if brokenLinks > 0 { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.High) - check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks) - check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability") - details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks) - check.Details = &details - } else if warningLinks > 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.3 - check.Severity = api.PtrTo(api.Low) - check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks) - check.Advice = api.PtrTo("Review links that could not be verified") - details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) - check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links)) - check.Advice = api.PtrTo("Your links are working properly") - } - - checks = append(checks, check) - return checks -} - -// generateImageChecks creates checks for images -func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check { - var checks []api.Check - - if len(results.Images) == 0 { - return checks - } - - // Count images without alt text - noAltCount := 0 - for _, img := range results.Images { - if !img.HasAlt { - noAltCount++ - } - } - - check := api.Check{ - Category: api.Content, - Name: "Image Alt Attributes", - } - - if noAltCount == len(results.Images) { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) - check.Message = "No images have alt attributes" - check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability") - details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) - check.Details = &details - } else if noAltCount > 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) - check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount) - check.Advice = api.PtrTo("Add alt text to all images for better accessibility") - details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) - check.Message = "All images have alt attributes" - check.Advice = api.PtrTo("Your images are properly tagged for accessibility") - } - - checks = append(checks, check) - return checks -} - -// generateUnsubscribeCheck creates a check for unsubscribe links -func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Unsubscribe Link", - } - - if !results.HasUnsubscribe { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) - check.Message = "No unsubscribe link found" - check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)") - } else { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) - check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks)) - check.Advice = api.PtrTo("Your email includes an unsubscribe option") - } - - return check -} - -// generateTextConsistencyCheck creates a check for text/HTML consistency -func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Plain Text Consistency", - } - - consistency := results.TextPlainRatio - - if consistency < 0.3 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) - check.Message = "Plain text and HTML versions differ significantly" - check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content") - details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) - check.Message = "Plain text and HTML versions are consistent" - check.Advice = api.PtrTo("Your multipart email is well-structured") - details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) - check.Details = &details - } - - return check -} - -// generateImageRatioCheck creates a check for image-to-text ratio -func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Image-to-Text Ratio", - } - - ratio := results.ImageTextRatio - - // Flag if more than 1 image per 100 characters (very image-heavy) - if ratio > 10.0 { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) - check.Message = "Email is excessively image-heavy" - check.Advice = api.PtrTo("Reduce the number of images relative to text content") - details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) - check.Details = &details - } else if ratio > 5.0 { - check.Status = api.CheckStatusWarn - check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) - check.Message = "Email has high image-to-text ratio" - check.Advice = api.PtrTo("Consider adding more text content relative to images") - details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) - check.Message = "Image-to-text ratio is reasonable" - check.Advice = api.PtrTo("Your content has a good balance of images and text") - details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) - check.Details = &details - } - - return check -} - -// generateSuspiciousURLCheck creates a check for suspicious URLs -func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Suspicious URLs", - } - - count := len(results.SuspiciousURLs) - - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) - check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count) - check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails") - - if count <= 3 { - details := strings.Join(results.SuspiciousURLs, ", ") - check.Details = &details - } else { - details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3) - check.Details = &details - } - - return check -} - -// GetContentScore calculates the content score (0-2 points) -func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { - if results == nil { - return 0.0 - } - - var score float32 = 0.0 - - // HTML validity (0.2 points) - if results.HTMLValid { - score += 0.2 - } - - // Links (0.4 points) - if len(results.Links) > 0 { - brokenLinks := 0 - for _, link := range results.Links { - if link.Status >= 400 { - brokenLinks++ - } - } - if brokenLinks == 0 { - score += 0.4 - } - } else { - // No links is neutral, give partial score - score += 0.2 - } - - // Images (0.3 points) - if len(results.Images) > 0 { - noAltCount := 0 - for _, img := range results.Images { - if !img.HasAlt { - noAltCount++ - } - } - if noAltCount == 0 { - score += 0.3 - } else if noAltCount < len(results.Images) { - score += 0.15 - } - } else { - // No images is neutral - score += 0.15 - } - - // Unsubscribe link (0.3 points) - if results.HasUnsubscribe { - score += 0.3 - } - - // Text consistency (0.3 points) - if results.TextPlainRatio >= 0.3 { - score += 0.3 - } - - // Image ratio (0.3 points) - if results.ImageTextRatio <= 5.0 { - score += 0.3 - } else if results.ImageTextRatio <= 10.0 { - score += 0.15 - } - - // Penalize suspicious URLs (deduct up to 0.5 points) - if len(results.SuspiciousURLs) > 0 { - penalty := float32(len(results.SuspiciousURLs)) * 0.1 - if penalty > 0.5 { - penalty = 0.5 - } - score -= penalty - } - - // Ensure score is between 0 and 2 - if score < 0 { - score = 0 - } - if score > 2.0 { - score = 2.0 - } - - return score -} diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go deleted file mode 100644 index 07c0346..0000000 --- a/internal/analyzer/dns.go +++ /dev/null @@ -1,566 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "context" - "fmt" - "net" - "regexp" - "strings" - "time" - - "git.happydns.org/happyDeliver/internal/api" -) - -// DNSAnalyzer analyzes DNS records for email domains -type DNSAnalyzer struct { - Timeout time.Duration - resolver *net.Resolver -} - -// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout -func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { - if timeout == 0 { - timeout = 10 * time.Second // Default timeout - } - return &DNSAnalyzer{ - Timeout: timeout, - resolver: &net.Resolver{ - PreferGo: true, - }, - } -} - -// DNSResults represents DNS validation results for an email -type DNSResults struct { - Domain string - MXRecords []MXRecord - SPFRecord *SPFRecord - DKIMRecords []DKIMRecord - DMARCRecord *DMARCRecord - Errors []string -} - -// MXRecord represents an MX record -type MXRecord struct { - Host string - Priority uint16 - Valid bool - Error string -} - -// SPFRecord represents an SPF record -type SPFRecord struct { - Record string - Valid bool - Error string -} - -// DKIMRecord represents a DKIM record -type DKIMRecord struct { - Selector string - Domain string - Record string - Valid bool - Error string -} - -// DMARCRecord represents a DMARC record -type DMARCRecord struct { - Record string - Policy string // none, quarantine, reject - Valid bool - Error string -} - -// AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { - // Extract domain from From address - domain := d.extractDomain(email) - if domain == "" { - return &DNSResults{ - Errors: []string{"Unable to extract domain from email"}, - } - } - - results := &DNSResults{ - Domain: domain, - } - - // Check MX records - results.MXRecords = d.checkMXRecords(domain) - - // Check SPF record - results.SPFRecord = d.checkSPFRecord(domain) - - // Check DKIM records (from authentication results) - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) - } - } - } - } - - // Check DMARC record - results.DMARCRecord = d.checkDMARCRecord(domain) - - return results -} - -// extractDomain extracts the domain from the email's From address -func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { - if email.From != nil && email.From.Address != "" { - parts := strings.Split(email.From.Address, "@") - if len(parts) == 2 { - return strings.ToLower(strings.TrimSpace(parts[1])) - } - } - return "" -} - -// checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - mxRecords, err := d.resolver.LookupMX(ctx, domain) - if err != nil { - return []MXRecord{ - { - Valid: false, - Error: fmt.Sprintf("Failed to lookup MX records: %v", err), - }, - } - } - - if len(mxRecords) == 0 { - return []MXRecord{ - { - Valid: false, - Error: "No MX records found", - }, - } - } - - var results []MXRecord - for _, mx := range mxRecords { - results = append(results, MXRecord{ - Host: mx.Host, - Priority: mx.Pref, - Valid: true, - }) - } - - return results -} - -// checkSPFRecord looks up and validates SPF record for a domain -func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, domain) - if err != nil { - return &SPFRecord{ - Valid: false, - Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), - } - } - - // Find SPF record (starts with "v=spf1") - var spfRecord string - spfCount := 0 - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=spf1") { - spfRecord = txt - spfCount++ - } - } - - if spfCount == 0 { - return &SPFRecord{ - Valid: false, - Error: "No SPF record found", - } - } - - if spfCount > 1 { - return &SPFRecord{ - Record: spfRecord, - Valid: false, - Error: "Multiple SPF records found (RFC violation)", - } - } - - // Basic validation - if !d.validateSPF(spfRecord) { - return &SPFRecord{ - Record: spfRecord, - Valid: false, - Error: "SPF record appears malformed", - } - } - - return &SPFRecord{ - Record: spfRecord, - Valid: true, - } -} - -// validateSPF performs basic SPF record validation -func (d *DNSAnalyzer) validateSPF(record string) bool { - // Must start with v=spf1 - if !strings.HasPrefix(record, "v=spf1") { - return false - } - - // Check for common syntax issues - // Should have a final mechanism (all, +all, -all, ~all, ?all) - validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} - hasValidEnding := false - for _, ending := range validEndings { - if strings.HasSuffix(record, ending) { - hasValidEnding = true - break - } - } - - return hasValidEnding -} - -// checkDKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { - // DKIM records are at: selector._domainkey.domain - dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) - if err != nil { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), - } - } - - if len(txtRecords) == 0 { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: "No DKIM record found", - } - } - - // Concatenate all TXT record parts (DKIM can be split) - dkimRecord := strings.Join(txtRecords, "") - - // Basic validation - should contain "v=DKIM1" and "p=" (public key) - if !d.validateDKIM(dkimRecord) { - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Record: dkimRecord, - Valid: false, - Error: "DKIM record appears malformed", - } - } - - return &DKIMRecord{ - Selector: selector, - Domain: domain, - Record: dkimRecord, - Valid: true, - } -} - -// validateDKIM performs basic DKIM record validation -func (d *DNSAnalyzer) validateDKIM(record string) bool { - // Should contain p= tag (public key) - if !strings.Contains(record, "p=") { - return false - } - - // Often contains v=DKIM1 but not required - // If v= is present, it should be DKIM1 - if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { - return false - } - - return true -} - -// checkDMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { - // DMARC records are at: _dmarc.domain - dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) - if err != nil { - return &DMARCRecord{ - Valid: false, - Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), - } - } - - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break - } - } - - if dmarcRecord == "" { - return &DMARCRecord{ - Valid: false, - Error: "No DMARC record found", - } - } - - // Extract policy - policy := d.extractDMARCPolicy(dmarcRecord) - - // Basic validation - if !d.validateDMARC(dmarcRecord) { - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, - Valid: false, - Error: "DMARC record appears malformed", - } - } - - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, - Valid: true, - } -} - -// extractDMARCPolicy extracts the policy from a DMARC record -func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { - // Look for p=none, p=quarantine, or p=reject - re := regexp.MustCompile(`p=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "unknown" -} - -// validateDMARC performs basic DMARC record validation -func (d *DNSAnalyzer) validateDMARC(record string) bool { - // Must start with v=DMARC1 - if !strings.HasPrefix(record, "v=DMARC1") { - return false - } - - // Must have a policy tag - if !strings.Contains(record, "p=") { - return false - } - - return true -} - -// GenerateDNSChecks generates check results for DNS validation -func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // MX record check - checks = append(checks, d.generateMXCheck(results)) - - // SPF record check - if results.SPFRecord != nil { - checks = append(checks, d.generateSPFCheck(results.SPFRecord)) - } - - // DKIM record checks - for _, dkim := range results.DKIMRecords { - checks = append(checks, d.generateDKIMCheck(&dkim)) - } - - // DMARC record check - if results.DMARCRecord != nil { - checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) - } - - return checks -} - -// generateMXCheck creates a check for MX records -func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "MX Records", - } - - if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) - - if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { - check.Message = results.MXRecords[0].Error - } else { - check.Message = "No valid MX records found" - } - check.Advice = api.PtrTo("Configure MX records for your domain to receive email") - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) - check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) - - // Add details about MX records - var mxList []string - for _, mx := range results.MXRecords { - mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority)) - } - details := strings.Join(mxList, ", ") - check.Details = &details - check.Advice = api.PtrTo("Your MX records are properly configured") - } - - return check -} - -// generateSPFCheck creates a check for SPF records -func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "SPF Record", - } - - if !spf.Valid { - // If no record exists at all, it's a failure - if spf.Record == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = spf.Error - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") - } else { - // If record exists but is invalid, it's a warning - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Review and fix your SPF record syntax") - check.Details = &spf.Record - } - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "Valid SPF record found" - check.Severity = api.PtrTo(api.Info) - check.Details = &spf.Record - check.Advice = api.PtrTo("Your SPF record is properly configured") - } - - return check -} - -// generateDKIMCheck creates a check for DKIM records -func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector), - } - - if !dkim.Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "Valid DKIM record found" - check.Severity = api.PtrTo(api.Info) - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - check.Advice = api.PtrTo("Your DKIM record is properly published") - } - - return check -} - -// generateDMARCCheck creates a check for DMARC records -func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "DMARC Record", - } - - if !dmarc.Valid { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = dmarc.Error - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) - check.Severity = api.PtrTo(api.Info) - check.Details = &dmarc.Record - - // Provide advice based on policy - switch dmarc.Policy { - case "none": - advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection" - check.Advice = &advice - case "quarantine": - advice := "DMARC policy is set to 'quarantine'. This provides good protection" - check.Advice = &advice - case "reject": - advice := "DMARC policy is set to 'reject'. This provides the strongest protection" - check.Advice = &advice - default: - advice := "Your DMARC record is properly configured" - check.Advice = &advice - } - } - - return check -} diff --git a/internal/analyzer/dns_test.go b/internal/analyzer/dns_test.go deleted file mode 100644 index fe501d5..0000000 --- a/internal/analyzer/dns_test.go +++ /dev/null @@ -1,633 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "net/mail" - "strings" - "testing" - "time" - - "git.happydns.org/happyDeliver/internal/api" -) - -func TestNewDNSAnalyzer(t *testing.T) { - tests := []struct { - name string - timeout time.Duration - expectedTimeout time.Duration - }{ - { - name: "Default timeout", - timeout: 0, - expectedTimeout: 10 * time.Second, - }, - { - name: "Custom timeout", - timeout: 5 * time.Second, - expectedTimeout: 5 * time.Second, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - analyzer := NewDNSAnalyzer(tt.timeout) - if analyzer.Timeout != tt.expectedTimeout { - t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout) - } - if analyzer.resolver == nil { - t.Error("Resolver should not be nil") - } - }) - } -} - -func TestExtractDomain(t *testing.T) { - tests := []struct { - name string - fromAddress string - expectedDomain string - }{ - { - name: "Valid email", - fromAddress: "user@example.com", - expectedDomain: "example.com", - }, - { - name: "Email with subdomain", - fromAddress: "user@mail.example.com", - expectedDomain: "mail.example.com", - }, - { - name: "Email with uppercase", - fromAddress: "User@Example.COM", - expectedDomain: "example.com", - }, - { - name: "Invalid email (no @)", - fromAddress: "invalid-email", - expectedDomain: "", - }, - { - name: "Empty email", - fromAddress: "", - expectedDomain: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - } - if tt.fromAddress != "" { - email.From = &mail.Address{ - Address: tt.fromAddress, - } - } - - domain := analyzer.extractDomain(email) - if domain != tt.expectedDomain { - t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain) - } - }) - } -} - -func TestValidateSPF(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid SPF with -all", - record: "v=spf1 include:_spf.example.com -all", - expected: true, - }, - { - name: "Valid SPF with ~all", - record: "v=spf1 ip4:192.0.2.0/24 ~all", - expected: true, - }, - { - name: "Valid SPF with +all", - record: "v=spf1 +all", - expected: true, - }, - { - name: "Valid SPF with ?all", - record: "v=spf1 mx ?all", - expected: true, - }, - { - name: "Invalid SPF - no version", - record: "include:_spf.example.com -all", - expected: false, - }, - { - name: "Invalid SPF - no all mechanism", - record: "v=spf1 include:_spf.example.com", - expected: false, - }, - { - name: "Invalid SPF - wrong version", - record: "v=spf2 include:_spf.example.com -all", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateSPF(tt.record) - if result != tt.expected { - t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestValidateDKIM(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DKIM with version", - record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Valid DKIM without version", - record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Invalid DKIM - no public key", - record: "v=DKIM1; k=rsa", - expected: false, - }, - { - name: "Invalid DKIM - wrong version", - record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: false, - }, - { - name: "Invalid DKIM - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDKIM(tt.record) - if result != tt.expected { - t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractDMARCPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy string - }{ - { - name: "Policy none", - record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", - expectedPolicy: "none", - }, - { - name: "Policy quarantine", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPolicy: "quarantine", - }, - { - name: "Policy reject", - record: "v=DMARC1; p=reject; sp=reject", - expectedPolicy: "reject", - }, - { - name: "No policy", - record: "v=DMARC1", - expectedPolicy: "unknown", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) - } - }) - } -} - -func TestValidateDMARC(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DMARC", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - expected: true, - }, - { - name: "Valid DMARC minimal", - record: "v=DMARC1; p=none", - expected: true, - }, - { - name: "Invalid DMARC - no version", - record: "p=quarantine", - expected: false, - }, - { - name: "Invalid DMARC - no policy", - record: "v=DMARC1", - expected: false, - }, - { - name: "Invalid DMARC - wrong version", - record: "v=DMARC2; p=reject", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDMARC(tt.record) - if result != tt.expected { - t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestGenerateMXCheck(t *testing.T) { - tests := []struct { - name string - results *DNSResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - {Host: "mail2.example.com", Priority: 20, Valid: true}, - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "No MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "No MX records found"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "MX lookup failed", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "DNS lookup failed"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateMXCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateSPFCheck(t *testing.T) { - tests := []struct { - name string - spf *SPFRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid SPF", - spf: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Invalid SPF", - spf: &SPFRecord{ - Record: "v=spf1 invalid syntax", - Valid: false, - Error: "SPF record appears malformed", - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - { - name: "No SPF record", - spf: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateSPFCheck(tt.spf) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateDKIMCheck(t *testing.T) { - tests := []struct { - name string - dkim *DKIMRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=MIGfMA0...", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Invalid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Valid: false, - Error: "No DKIM record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDKIMCheck(tt.dkim) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - if !strings.Contains(check.Name, tt.dkim.Selector) { - t.Errorf("Check name should contain selector %s", tt.dkim.Selector) - } - }) - } -} - -func TestGenerateDMARCCheck(t *testing.T) { - tests := []struct { - name string - dmarc *DMARCRecord - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Valid DMARC - reject", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=reject", - Policy: "reject", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Valid DMARC - quarantine", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "Valid DMARC - none", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=none", - Policy: "none", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, - }, - { - name: "No DMARC record", - dmarc: &DMARCRecord{ - Valid: false, - Error: "No DMARC record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDMARCCheck(tt.dmarc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - - // Check that advice mentions policy for valid DMARC - if tt.dmarc.Valid && check.Advice != nil { - if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") { - t.Error("Advice should mention 'none' policy") - } - } - }) - } -} - -func TestGenerateDNSChecks(t *testing.T) { - tests := []struct { - name string - results *DNSResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "Complete results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - }, - minChecks: 4, // MX, SPF, DKIM, DMARC - }, - { - name: "Partial results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - }, - minChecks: 1, // Only MX - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateDNSChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the DNS category - for _, check := range checks { - if check.Category != api.Dns { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns) - } - } - }) - } -} - -func TestAnalyzeDNS_NoDomain(t *testing.T) { - analyzer := NewDNSAnalyzer(5 * time.Second) - email := &EmailMessage{ - Header: make(mail.Header), - // No From address - } - - results := analyzer.AnalyzeDNS(email, nil) - - if results == nil { - t.Fatal("Expected results, got nil") - } - - if len(results.Errors) == 0 { - t.Error("Expected error when no domain can be extracted") - } -} diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go deleted file mode 100644 index be7366c..0000000 --- a/internal/analyzer/rbl.go +++ /dev/null @@ -1,408 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "context" - "fmt" - "net" - "regexp" - "strings" - "time" - - "git.happydns.org/happyDeliver/internal/api" -) - -// RBLChecker checks IP addresses against DNS-based blacklists -type RBLChecker struct { - Timeout time.Duration - RBLs []string - resolver *net.Resolver -} - -// DefaultRBLs is a list of commonly used RBL providers -var DefaultRBLs = []string{ - "zen.spamhaus.org", // Spamhaus combined list - "bl.spamcop.net", // SpamCop - "dnsbl.sorbs.net", // SORBS - "b.barracudacentral.org", // Barracuda - "cbl.abuseat.org", // CBL (Composite Blocking List) - "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 -} - -// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list -func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { - if timeout == 0 { - timeout = 5 * time.Second // Default timeout - } - if len(rbls) == 0 { - rbls = DefaultRBLs - } - return &RBLChecker{ - Timeout: timeout, - RBLs: rbls, - resolver: &net.Resolver{ - PreferGo: true, - }, - } -} - -// RBLResults represents the results of RBL checks -type RBLResults struct { - Checks []RBLCheck - IPsChecked []string - ListedCount int -} - -// RBLCheck represents a single RBL check result -type RBLCheck struct { - IP string - RBL string - Listed bool - Response string - Error string -} - -// CheckEmail checks all IPs found in the email headers against RBLs -func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{} - - // Extract IPs from Received headers - ips := r.extractIPs(email) - if len(ips) == 0 { - return results - } - - results.IPsChecked = ips - - // Check each IP against all RBLs - for _, ip := range ips { - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) - results.Checks = append(results.Checks, check) - if check.Listed { - results.ListedCount++ - } - } - } - - return results -} - -// extractIPs extracts IP addresses from Received headers -func (r *RBLChecker) extractIPs(email *EmailMessage) []string { - var ips []string - seenIPs := make(map[string]bool) - - // Get all Received headers - receivedHeaders := email.Header["Received"] - - // Regex patterns for IP addresses - // Match IPv4: xxx.xxx.xxx.xxx - ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) - - // Look for IPs in Received headers - for _, received := range receivedHeaders { - // Find all IPv4 addresses - matches := ipv4Pattern.FindAllString(received, -1) - for _, match := range matches { - // Skip private/reserved IPs - if !r.isPublicIP(match) { - continue - } - // Avoid duplicates - if !seenIPs[match] { - ips = append(ips, match) - seenIPs[match] = true - } - } - } - - // 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) { - ips = append(ips, matches) - } - } - } - - return ips -} - -// isPublicIP checks if an IP address is public (not private, loopback, or reserved) -func (r *RBLChecker) isPublicIP(ipStr string) bool { - ip := net.ParseIP(ipStr) - if ip == nil { - return false - } - - // Check if it's a private network - if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - return false - } - - // Additional checks for reserved ranges - // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) - if ip.IsUnspecified() { - return false - } - - return true -} - -// checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { - check := RBLCheck{ - IP: ip, - RBL: rbl, - } - - // Reverse the IP for DNSBL query - reversedIP := r.reverseIP(ip) - if reversedIP == "" { - check.Error = "Failed to reverse IP address" - return check - } - - // Construct DNSBL query: reversed-ip.rbl-domain - query := fmt.Sprintf("%s.%s", reversedIP, rbl) - - // Perform DNS lookup with timeout - ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) - defer cancel() - - addrs, err := r.resolver.LookupHost(ctx, query) - if err != nil { - // Most likely not listed (NXDOMAIN) - if dnsErr, ok := err.(*net.DNSError); ok { - if dnsErr.IsNotFound { - check.Listed = false - return check - } - } - // Other DNS errors - check.Error = fmt.Sprintf("DNS lookup failed: %v", err) - return check - } - - // If we got a response, the IP is listed - if len(addrs) > 0 { - check.Listed = true - check.Response = addrs[0] // Return code (e.g., 127.0.0.2) - } - - return check -} - -// reverseIP reverses an IPv4 address for DNSBL queries -// Example: 192.0.2.1 -> 1.2.0.192 -func (r *RBLChecker) reverseIP(ipStr string) string { - ip := net.ParseIP(ipStr) - if ip == nil { - return "" - } - - // Convert to IPv4 - ipv4 := ip.To4() - if ipv4 == nil { - return "" // IPv6 not supported yet - } - - // Reverse the octets - return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) -} - -// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points) -// Scoring: -// - Not listed on any RBL: 2 points (excellent) -// - Listed on 1 RBL: 1 point (warning) -// - Listed on 2-3 RBLs: 0.5 points (poor) -// - Listed on 4+ RBLs: 0 points (critical) -func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { - if results == nil || len(results.IPsChecked) == 0 { - // No IPs to check, give benefit of doubt - return 2.0 - } - - listedCount := results.ListedCount - - if listedCount == 0 { - return 2.0 - } else if listedCount == 1 { - return 1.0 - } else if listedCount <= 3 { - return 0.5 - } - - return 0.0 -} - -// GenerateRBLChecks generates check results for RBL analysis -func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // If no IPs were checked, add a warning - if len(results.IPsChecked) == 0 { - checks = append(checks, api.Check{ - Category: api.Blacklist, - Name: "RBL Check", - Status: api.CheckStatusWarn, - Score: 1.0, - Message: "No public IP addresses found to check", - Severity: api.PtrTo(api.Low), - Advice: api.PtrTo("Unable to extract sender IP from email headers"), - }) - return checks - } - - // Create a summary check - summaryCheck := r.generateSummaryCheck(results) - checks = append(checks, summaryCheck) - - // Create individual checks for each listing - for _, check := range results.Checks { - if check.Listed { - detailCheck := r.generateListingCheck(&check) - checks = append(checks, detailCheck) - } - } - - return checks -} - -// generateSummaryCheck creates an overall RBL summary check -func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: "RBL Summary", - } - - score := r.GetBlacklistScore(results) - check.Score = score - - totalChecks := len(results.Checks) - listedCount := results.ListedCount - - if listedCount == 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs)) - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your sending IP has a good reputation") - } else if listedCount == 1 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") - } else if listedCount <= 3 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") - } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.Critical) - check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") - } - - // Add details about IPs checked - if len(results.IPsChecked) > 0 { - details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", ")) - check.Details = &details - } - - return check -} - -// generateListingCheck creates a check for a specific RBL listing -func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), - Status: api.CheckStatusFail, - Score: 0.0, - } - - check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) - - // Determine severity based on which RBL - if strings.Contains(rblCheck.RBL, "spamhaus") { - check.Severity = api.PtrTo(api.Critical) - advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") - check.Advice = &advice - } else if strings.Contains(rblCheck.RBL, "spamcop") { - check.Severity = api.PtrTo(api.High) - advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") - check.Advice = &advice - } else { - check.Severity = api.PtrTo(api.High) - advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) - check.Advice = &advice - } - - // Add response code details - if rblCheck.Response != "" { - details := fmt.Sprintf("Response: %s", rblCheck.Response) - check.Details = &details - } - - return check -} - -// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL -func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { - seenIPs := make(map[string]bool) - var listedIPs []string - - for _, check := range results.Checks { - if check.Listed && !seenIPs[check.IP] { - listedIPs = append(listedIPs, check.IP) - seenIPs[check.IP] = true - } - } - - return listedIPs -} - -// GetRBLsForIP returns all RBLs that list a specific IP -func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { - var rbls []string - - for _, check := range results.Checks { - if check.IP == ip && check.Listed { - rbls = append(rbls, check.RBL) - } - } - - return rbls -} diff --git a/internal/analyzer/report.go b/internal/analyzer/report.go deleted file mode 100644 index fe30c6c..0000000 --- a/internal/analyzer/report.go +++ /dev/null @@ -1,348 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "time" - - "git.happydns.org/happyDeliver/internal/api" - "github.com/google/uuid" -) - -// ReportGenerator generates comprehensive deliverability reports -type ReportGenerator struct { - authAnalyzer *AuthenticationAnalyzer - spamAnalyzer *SpamAssassinAnalyzer - dnsAnalyzer *DNSAnalyzer - rblChecker *RBLChecker - contentAnalyzer *ContentAnalyzer - scorer *DeliverabilityScorer -} - -// NewReportGenerator creates a new report generator -func NewReportGenerator( - dnsTimeout time.Duration, - httpTimeout time.Duration, - rbls []string, -) *ReportGenerator { - return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(), - spamAnalyzer: NewSpamAssassinAnalyzer(), - dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), - rblChecker: NewRBLChecker(dnsTimeout, rbls), - contentAnalyzer: NewContentAnalyzer(httpTimeout), - scorer: NewDeliverabilityScorer(), - } -} - -// AnalysisResults contains all intermediate analysis results -type AnalysisResults struct { - Email *EmailMessage - Authentication *api.AuthenticationResults - SpamAssassin *SpamAssassinResult - DNS *DNSResults - RBL *RBLResults - Content *ContentResults - Score *ScoringResult -} - -// AnalyzeEmail performs complete email analysis -func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { - results := &AnalysisResults{ - Email: email, - } - - // Run all analyzers - results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) - results.RBL = r.rblChecker.CheckEmail(email) - results.Content = r.contentAnalyzer.AnalyzeContent(email) - - // Calculate overall score - results.Score = r.scorer.CalculateScore( - results.Authentication, - results.SpamAssassin, - results.RBL, - results.Content, - email, - ) - - return results -} - -// GenerateReport creates a complete API report from analysis results -func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report { - reportID := uuid.New() - now := time.Now() - - report := &api.Report{ - Id: reportID, - TestId: testID, - Score: results.Score.OverallScore, - CreatedAt: now, - } - - // Build score summary - report.Summary = &api.ScoreSummary{ - AuthenticationScore: results.Score.AuthScore, - SpamScore: results.Score.SpamScore, - BlacklistScore: results.Score.BlacklistScore, - ContentScore: results.Score.ContentScore, - HeaderScore: results.Score.HeaderScore, - } - - // Collect all checks from different analyzers - checks := []api.Check{} - - // Authentication checks - if results.Authentication != nil { - authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication) - checks = append(checks, authChecks...) - } - - // DNS checks - if results.DNS != nil { - dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS) - checks = append(checks, dnsChecks...) - } - - // RBL checks - if results.RBL != nil { - rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL) - checks = append(checks, rblChecks...) - } - - // SpamAssassin checks - if results.SpamAssassin != nil { - spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin) - checks = append(checks, spamChecks...) - } - - // Content checks - if results.Content != nil { - contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content) - checks = append(checks, contentChecks...) - } - - // Header checks - headerChecks := r.scorer.GenerateHeaderChecks(results.Email) - checks = append(checks, headerChecks...) - - report.Checks = checks - - // Add authentication results - report.Authentication = results.Authentication - - // Add SpamAssassin result - if results.SpamAssassin != nil { - report.Spamassassin = &api.SpamAssassinResult{ - Score: float32(results.SpamAssassin.Score), - RequiredScore: float32(results.SpamAssassin.RequiredScore), - IsSpam: results.SpamAssassin.IsSpam, - } - - if len(results.SpamAssassin.Tests) > 0 { - report.Spamassassin.Tests = &results.SpamAssassin.Tests - } - - if results.SpamAssassin.RawReport != "" { - report.Spamassassin.Report = &results.SpamAssassin.RawReport - } - } - - // Add DNS records - if results.DNS != nil { - dnsRecords := r.buildDNSRecords(results.DNS) - if len(dnsRecords) > 0 { - report.DnsRecords = &dnsRecords - } - } - - // Add blacklist checks - if results.RBL != nil && len(results.RBL.Checks) > 0 { - blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) - for _, check := range results.RBL.Checks { - blCheck := api.BlacklistCheck{ - Ip: check.IP, - Rbl: check.RBL, - Listed: check.Listed, - } - if check.Response != "" { - blCheck.Response = &check.Response - } - blacklistChecks = append(blacklistChecks, blCheck) - } - report.Blacklists = &blacklistChecks - } - - // Add raw headers - if results.Email != nil && results.Email.RawHeaders != "" { - report.RawHeaders = &results.Email.RawHeaders - } - - return report -} - -// buildDNSRecords converts DNS analysis results to API DNS records -func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { - records := []api.DNSRecord{} - - if dns == nil { - return records - } - - // MX records - if len(dns.MXRecords) > 0 { - for _, mx := range dns.MXRecords { - status := api.Found - if !mx.Valid { - if mx.Error != "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.MX, - Status: status, - } - - if mx.Host != "" { - value := mx.Host - record.Value = &value - } - - records = append(records, record) - } - } - - // SPF record - if dns.SPFRecord != nil { - status := api.Found - if !dns.SPFRecord.Valid { - if dns.SPFRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.SPF, - Status: status, - } - - if dns.SPFRecord.Record != "" { - record.Value = &dns.SPFRecord.Record - } - - records = append(records, record) - } - - // DKIM records - for _, dkim := range dns.DKIMRecords { - status := api.Found - if !dkim.Valid { - if dkim.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dkim.Domain, - RecordType: api.DKIM, - Status: status, - } - - if dkim.Record != "" { - // Include selector in value for clarity - value := dkim.Record - record.Value = &value - } - - records = append(records, record) - } - - // DMARC record - if dns.DMARCRecord != nil { - status := api.Found - if !dns.DMARCRecord.Valid { - if dns.DMARCRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.DMARC, - Status: status, - } - - if dns.DMARCRecord.Record != "" { - record.Value = &dns.DMARCRecord.Record - } - - records = append(records, record) - } - - return records -} - -// GenerateRawEmail returns the raw email message as a string -func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { - if email == nil { - return "" - } - - raw := email.RawHeaders - if email.RawBody != "" { - raw += "\n" + email.RawBody - } - - return raw -} - -// GetRecommendations returns actionable recommendations based on the score -func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { - if results == nil || results.Score == nil { - return []string{} - } - - return results.Score.Recommendations -} - -// GetScoreSummaryText returns a human-readable score summary -func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { - if results == nil || results.Score == nil { - return "" - } - - return r.scorer.GetScoreSummary(results.Score) -} diff --git a/internal/analyzer/report_test.go b/internal/analyzer/report_test.go deleted file mode 100644 index 4a8fe00..0000000 --- a/internal/analyzer/report_test.go +++ /dev/null @@ -1,501 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "net/mail" - "net/textproto" - "strings" - "testing" - "time" - - "git.happydns.org/happyDeliver/internal/api" - "github.com/google/uuid" -) - -func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - if gen == nil { - t.Fatal("Expected report generator, got nil") - } - - if gen.authAnalyzer == nil { - t.Error("authAnalyzer should not be nil") - } - if gen.spamAnalyzer == nil { - t.Error("spamAnalyzer should not be nil") - } - if gen.dnsAnalyzer == nil { - t.Error("dnsAnalyzer should not be nil") - } - if gen.rblChecker == nil { - t.Error("rblChecker should not be nil") - } - if gen.contentAnalyzer == nil { - t.Error("contentAnalyzer should not be nil") - } - if gen.scorer == nil { - t.Error("scorer should not be nil") - } -} - -func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - email := createTestEmail() - - results := gen.AnalyzeEmail(email) - - if results == nil { - t.Fatal("Expected analysis results, got nil") - } - - if results.Email == nil { - t.Error("Email should not be nil") - } - - if results.Authentication == nil { - t.Error("Authentication should not be nil") - } - - // SpamAssassin might be nil if headers don't exist - // DNS results should exist - // RBL results should exist - // Content results should exist - - if results.Score == nil { - t.Error("Score should not be nil") - } - - // Verify score is within bounds - if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { - t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) - } -} - -func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - testID := uuid.New() - - email := createTestEmail() - results := gen.AnalyzeEmail(email) - - report := gen.GenerateReport(testID, results) - - if report == nil { - t.Fatal("Expected report, got nil") - } - - // Verify required fields - if report.Id == uuid.Nil { - t.Error("Report ID should not be empty") - } - - if report.TestId != testID { - t.Errorf("TestId = %s, want %s", report.TestId, testID) - } - - if report.Score < 0 || report.Score > 10 { - t.Errorf("Score %v is out of bounds", report.Score) - } - - if report.Summary == nil { - t.Error("Summary should not be nil") - } - - if len(report.Checks) == 0 { - t.Error("Checks should not be empty") - } - - // Verify score summary - if report.Summary != nil { - if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { - t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) - } - if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { - t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) - } - if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { - t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) - } - if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { - t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) - } - if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { - t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) - } - } - - // Verify checks have required fields - for i, check := range report.Checks { - if string(check.Category) == "" { - t.Errorf("Check %d: Category should not be empty", i) - } - if check.Name == "" { - t.Errorf("Check %d: Name should not be empty", i) - } - if string(check.Status) == "" { - t.Errorf("Check %d: Status should not be empty", i) - } - if check.Message == "" { - t.Errorf("Check %d: Message should not be empty", i) - } - } -} - -func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - testID := uuid.New() - - email := createTestEmailWithSpamAssassin() - results := gen.AnalyzeEmail(email) - - report := gen.GenerateReport(testID, results) - - if report.Spamassassin == nil { - t.Error("SpamAssassin result should not be nil") - } - - if report.Spamassassin != nil { - if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 { - t.Error("SpamAssassin scores should be set") - } - } -} - -func TestBuildDNSRecords(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - dns *DNSResults - expectedCount int - expectTypes []api.DNSRecordRecordType - }{ - { - name: "Nil DNS results", - dns: nil, - expectedCount: 0, - }, - { - name: "Complete DNS results", - dns: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=...", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Valid: true, - }, - }, - expectedCount: 4, // MX, SPF, DKIM, DMARC - expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC}, - }, - { - name: "Missing records", - dns: &DNSResults{ - Domain: "example.com", - SPFRecord: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - }, - expectedCount: 1, - expectTypes: []api.DNSRecordRecordType{api.SPF}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - records := gen.buildDNSRecords(tt.dns) - - if len(records) != tt.expectedCount { - t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount) - } - - // Verify expected types are present - if tt.expectTypes != nil { - foundTypes := make(map[api.DNSRecordRecordType]bool) - for _, record := range records { - foundTypes[record.RecordType] = true - } - - for _, expectedType := range tt.expectTypes { - if !foundTypes[expectedType] { - t.Errorf("Expected DNS record type %s not found", expectedType) - } - } - } - - // Verify all records have required fields - for i, record := range records { - if record.Domain == "" { - t.Errorf("Record %d: Domain should not be empty", i) - } - if string(record.RecordType) == "" { - t.Errorf("Record %d: RecordType should not be empty", i) - } - if string(record.Status) == "" { - t.Errorf("Record %d: Status should not be empty", i) - } - } - }) - } -} - -func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - email *EmailMessage - expected string - }{ - { - name: "Nil email", - email: nil, - expected: "", - }, - { - name: "Email with headers only", - email: &EmailMessage{ - RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n", - RawBody: "", - }, - expected: "From: sender@example.com\nTo: recipient@example.com\n", - }, - { - name: "Email with headers and body", - email: &EmailMessage{ - RawHeaders: "From: sender@example.com\n", - RawBody: "This is the email body", - }, - expected: "From: sender@example.com\n\nThis is the email body", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - raw := gen.GenerateRawEmail(tt.email) - if raw != tt.expected { - t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected) - } - }) - } -} - -func TestGetRecommendations(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectCount int - }{ - { - name: "Nil results", - results: nil, - expectCount: 0, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 1.0, - BlacklistScore: 1.5, - ContentScore: 0.5, - HeaderScore: 0.5, - Recommendations: []string{ - "Improve authentication", - "Fix content issues", - }, - }, - }, - expectCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recs := gen.GetRecommendations(tt.results) - if len(recs) != tt.expectCount { - t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount) - } - }) - } -} - -func TestGetScoreSummaryText(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectEmpty bool - expectString string - }{ - { - name: "Nil results", - results: nil, - expectEmpty: true, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - }, - }, - expectEmpty: false, - expectString: "8.5/10", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - summary := gen.GetScoreSummaryText(tt.results) - if tt.expectEmpty { - if summary != "" { - t.Errorf("Expected empty summary, got %q", summary) - } - } else { - if summary == "" { - t.Error("Expected non-empty summary") - } - if tt.expectString != "" && !strings.Contains(summary, tt.expectString) { - t.Errorf("Summary should contain %q, got %q", tt.expectString, summary) - } - } - }) - } -} - -func TestReportCategories(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - testID := uuid.New() - - email := createComprehensiveTestEmail() - results := gen.AnalyzeEmail(email) - report := gen.GenerateReport(testID, results) - - // Verify all check categories are present - categories := make(map[api.CheckCategory]bool) - for _, check := range report.Checks { - categories[check.Category] = true - } - - expectedCategories := []api.CheckCategory{ - api.Authentication, - api.Dns, - api.Headers, - } - - for _, cat := range expectedCategories { - if !categories[cat] { - t.Errorf("Expected category %s not found in checks", cat) - } - } -} - -// Helper functions - -func createTestEmail() *EmailMessage { - header := make(mail.Header) - header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"} - header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"} - header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"} - header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"} - header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{""} - - return &EmailMessage{ - Header: header, - From: &mail.Address{Address: "sender@example.com"}, - To: []*mail.Address{{Address: "recipient@example.com"}}, - Subject: "Test Email", - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{ - { - ContentType: "text/plain", - Content: "This is a test email", - IsText: true, - }, - }, - RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: \n", - RawBody: "This is a test email", - } -} - -func createTestEmailWithSpamAssassin() *EmailMessage { - email := createTestEmail() - email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"} - email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"} - email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} - return email -} - -func createComprehensiveTestEmail() *EmailMessage { - email := createTestEmailWithSpamAssassin() - - // Add authentication headers - email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{ - "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass", - } - - // Add HTML content - email.Parts = append(email.Parts, MessagePart{ - ContentType: "text/html", - Content: "

Test

Link", - IsHTML: true, - }) - - return email -} diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go deleted file mode 100644 index 07f6a34..0000000 --- a/internal/analyzer/scoring.go +++ /dev/null @@ -1,506 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "strings" - "time" - - "git.happydns.org/happyDeliver/internal/api" -) - -// DeliverabilityScorer aggregates all analysis results and computes overall score -type DeliverabilityScorer struct{} - -// NewDeliverabilityScorer creates a new deliverability scorer -func NewDeliverabilityScorer() *DeliverabilityScorer { - return &DeliverabilityScorer{} -} - -// ScoringResult represents the complete scoring result -type ScoringResult struct { - OverallScore float32 - Rating string // Excellent, Good, Fair, Poor, Critical - AuthScore float32 - SpamScore float32 - BlacklistScore float32 - ContentScore float32 - HeaderScore float32 - Recommendations []string - CategoryBreakdown map[string]CategoryScore -} - -// CategoryScore represents score breakdown for a category -type CategoryScore struct { - Score float32 - MaxScore float32 - Percentage float32 - Status string // Pass, Warn, Fail -} - -// CalculateScore computes the overall deliverability score from all analyzers -func (s *DeliverabilityScorer) CalculateScore( - authResults *api.AuthenticationResults, - spamResult *SpamAssassinResult, - rblResults *RBLResults, - contentResults *ContentResults, - email *EmailMessage, -) *ScoringResult { - result := &ScoringResult{ - CategoryBreakdown: make(map[string]CategoryScore), - Recommendations: []string{}, - } - - // Calculate individual scores - authAnalyzer := NewAuthenticationAnalyzer() - result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults) - - spamAnalyzer := NewSpamAssassinAnalyzer() - result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) - - rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs) - result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults) - - contentAnalyzer := NewContentAnalyzer(10 * time.Second) - result.ContentScore = contentAnalyzer.GetContentScore(contentResults) - - // Calculate header quality score - result.HeaderScore = s.calculateHeaderScore(email) - - // Calculate overall score (out of 10) - result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - - // Ensure score is within bounds - if result.OverallScore > 10.0 { - result.OverallScore = 10.0 - } - if result.OverallScore < 0.0 { - result.OverallScore = 0.0 - } - - // Determine rating - result.Rating = s.determineRating(result.OverallScore) - - // Build category breakdown - result.CategoryBreakdown["Authentication"] = CategoryScore{ - Score: result.AuthScore, - MaxScore: 3.0, - Percentage: (result.AuthScore / 3.0) * 100, - Status: s.getCategoryStatus(result.AuthScore, 3.0), - } - - result.CategoryBreakdown["Spam Filters"] = CategoryScore{ - Score: result.SpamScore, - MaxScore: 2.0, - Percentage: (result.SpamScore / 2.0) * 100, - Status: s.getCategoryStatus(result.SpamScore, 2.0), - } - - result.CategoryBreakdown["Blacklists"] = CategoryScore{ - Score: result.BlacklistScore, - MaxScore: 2.0, - Percentage: (result.BlacklistScore / 2.0) * 100, - Status: s.getCategoryStatus(result.BlacklistScore, 2.0), - } - - result.CategoryBreakdown["Content Quality"] = CategoryScore{ - Score: result.ContentScore, - MaxScore: 2.0, - Percentage: (result.ContentScore / 2.0) * 100, - Status: s.getCategoryStatus(result.ContentScore, 2.0), - } - - result.CategoryBreakdown["Email Structure"] = CategoryScore{ - Score: result.HeaderScore, - MaxScore: 1.0, - Percentage: (result.HeaderScore / 1.0) * 100, - Status: s.getCategoryStatus(result.HeaderScore, 1.0), - } - - // Generate recommendations - result.Recommendations = s.generateRecommendations(result) - - return result -} - -// calculateHeaderScore evaluates email structural quality (0-1 point) -func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { - if email == nil { - return 0.0 - } - - score := float32(0.0) - requiredHeaders := 0 - presentHeaders := 0 - - // Check required headers (RFC 5322) - headers := map[string]bool{ - "From": false, - "Date": false, - "Message-ID": false, - } - - for header := range headers { - requiredHeaders++ - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - headers[header] = true - presentHeaders++ - } - } - - // Score based on required headers (0.4 points) - if presentHeaders == requiredHeaders { - score += 0.4 - } else { - score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) - } - - // Check recommended headers (0.3 points) - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - recommendedPresent := 0 - for _, header := range recommendedHeaders { - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - recommendedPresent++ - } - } - score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - - // Check for proper MIME structure (0.2 points) - if len(email.Parts) > 0 { - score += 0.2 - } - - // Check Message-ID format (0.1 points) - if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { - if s.isValidMessageID(messageID) { - score += 0.1 - } - } - - // Ensure score doesn't exceed 1.0 - if score > 1.0 { - score = 1.0 - } - - return score -} - -// isValidMessageID checks if a Message-ID has proper format -func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { - // Basic check: should be in format <...@...> - if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { - return false - } - - // Remove angle brackets - messageID = strings.TrimPrefix(messageID, "<") - messageID = strings.TrimSuffix(messageID, ">") - - // Should contain @ symbol - if !strings.Contains(messageID, "@") { - return false - } - - parts := strings.Split(messageID, "@") - if len(parts) != 2 { - return false - } - - // Both parts should be non-empty - return len(parts[0]) > 0 && len(parts[1]) > 0 -} - -// determineRating determines the rating based on overall score -func (s *DeliverabilityScorer) determineRating(score float32) string { - switch { - case score >= 9.0: - return "Excellent" - case score >= 7.0: - return "Good" - case score >= 5.0: - return "Fair" - case score >= 3.0: - return "Poor" - default: - return "Critical" - } -} - -// getCategoryStatus determines status for a category -func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string { - percentage := (score / maxScore) * 100 - - switch { - case percentage >= 80.0: - return "Pass" - case percentage >= 50.0: - return "Warn" - default: - return "Fail" - } -} - -// generateRecommendations creates actionable recommendations based on scores -func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { - var recommendations []string - - // Authentication recommendations - if result.AuthScore < 2.0 { - recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") - } else if result.AuthScore < 3.0 { - recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") - } - - // Spam recommendations - if result.SpamScore < 1.0 { - recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") - } else if result.SpamScore < 1.5 { - recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") - } - - // Blacklist recommendations - if result.BlacklistScore < 1.0 { - recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") - } else if result.BlacklistScore < 2.0 { - recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") - } - - // Content recommendations - if result.ContentScore < 1.0 { - recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") - } else if result.ContentScore < 1.5 { - recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") - } - - // Header recommendations - if result.HeaderScore < 0.5 { - recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") - } else if result.HeaderScore < 1.0 { - recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") - } - - // Overall recommendations based on rating - if result.Rating == "Excellent" { - recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices") - } else if result.Rating == "Critical" { - recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam") - } - - return recommendations -} - -// GenerateHeaderChecks creates checks for email header quality -func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check { - var checks []api.Check - - if email == nil { - return checks - } - - // Required headers check - checks = append(checks, s.generateRequiredHeadersCheck(email)) - - // Recommended headers check - checks = append(checks, s.generateRecommendedHeadersCheck(email)) - - // Message-ID check - checks = append(checks, s.generateMessageIDCheck(email)) - - // MIME structure check - checks = append(checks, s.generateMIMEStructureCheck(email)) - - return checks -} - -// generateRequiredHeadersCheck checks for required RFC 5322 headers -func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Required Headers", - } - - requiredHeaders := []string{"From", "Date", "Message-ID"} - missing := []string{} - - for _, header := range requiredHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) - check.Message = "All required headers are present" - check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") - } else { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) - check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } - - return check -} - -// generateRecommendedHeadersCheck checks for recommended headers -func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Recommended Headers", - } - - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - missing := []string{} - - for _, header := range recommendedHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) - check.Message = "All recommended headers are present" - check.Advice = api.PtrTo("Your email includes all recommended headers") - } else if len(missing) < len(recommendedHeaders) { - check.Status = api.CheckStatusWarn - check.Score = 0.15 - check.Severity = api.PtrTo(api.Low) - check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } else { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) - check.Message = "Missing all recommended headers" - check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") - } - - return check -} - -// generateMessageIDCheck validates Message-ID header -func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Message-ID Format", - } - - messageID := email.GetHeaderValue("Message-ID") - - if messageID == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Severity = api.PtrTo(api.High) - check.Message = "Message-ID header is missing" - check.Advice = api.PtrTo("Add a unique Message-ID header to your email") - } else if !s.isValidMessageID(messageID) { - check.Status = api.CheckStatusWarn - check.Score = 0.05 - check.Severity = api.PtrTo(api.Medium) - check.Message = "Message-ID format is invalid" - check.Advice = api.PtrTo("Use proper Message-ID format: ") - check.Details = &messageID - } else { - check.Status = api.CheckStatusPass - check.Score = 0.1 - check.Severity = api.PtrTo(api.Info) - check.Message = "Message-ID is properly formatted" - check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") - check.Details = &messageID - } - - return check -} - -// generateMIMEStructureCheck validates MIME structure -func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "MIME Structure", - } - - if len(email.Parts) == 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) - check.Message = "No MIME parts detected" - check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") - } else { - check.Status = api.CheckStatusPass - check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) - check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) - check.Advice = api.PtrTo("Your email has proper MIME structure") - - // Add details about parts - partTypes := []string{} - for _, part := range email.Parts { - if part.ContentType != "" { - partTypes = append(partTypes, part.ContentType) - } - } - if len(partTypes) > 0 { - details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) - check.Details = &details - } - } - - return check -} - -// GetScoreSummary generates a human-readable summary of the score -func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { - var summary strings.Builder - - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating)) - summary.WriteString("Category Breakdown:\n") - summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n", - result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) - summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n", - result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) - summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n", - result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) - summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n", - result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) - summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n", - result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) - - if len(result.Recommendations) > 0 { - summary.WriteString("\nRecommendations:\n") - for _, rec := range result.Recommendations { - summary.WriteString(fmt.Sprintf(" %s\n", rec)) - } - } - - return summary.String() -} diff --git a/internal/analyzer/scoring_test.go b/internal/analyzer/scoring_test.go deleted file mode 100644 index b28182d..0000000 --- a/internal/analyzer/scoring_test.go +++ /dev/null @@ -1,762 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "net/mail" - "net/textproto" - "strings" - "testing" - - "git.happydns.org/happyDeliver/internal/api" -) - -func TestNewDeliverabilityScorer(t *testing.T) { - scorer := NewDeliverabilityScorer() - if scorer == nil { - t.Fatal("Expected scorer, got nil") - } -} - -func TestIsValidMessageID(t *testing.T) { - tests := []struct { - name string - messageID string - expected bool - }{ - { - name: "Valid Message-ID", - messageID: "", - expected: true, - }, - { - name: "Valid with UUID", - messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>", - expected: true, - }, - { - name: "Missing angle brackets", - messageID: "abc123@example.com", - expected: false, - }, - { - name: "Missing @ symbol", - messageID: "", - expected: false, - }, - { - name: "Multiple @ symbols", - messageID: "", - expected: false, - }, - { - name: "Empty local part", - messageID: "<@example.com>", - expected: false, - }, - { - name: "Empty domain part", - messageID: "", - expected: false, - }, - { - name: "Empty", - messageID: "", - expected: false, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.isValidMessageID(tt.messageID) - if result != tt.expected { - t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) - } - }) - } -} - -func TestCalculateHeaderScore(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minScore float32 - maxScore float32 - }{ - { - name: "Nil email", - email: nil, - minScore: 0.0, - maxScore: 0.0, - }, - { - name: "Perfect headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.7, - maxScore: 1.0, - }, - { - name: "Missing required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Subject": "Test", - }), - }, - minScore: 0.0, - maxScore: 0.4, - }, - { - name: "Required only, no recommended", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.4, - maxScore: 0.8, - }, - { - name: "Invalid Message-ID format", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "invalid-message-id", - "Subject": "Test", - "To": "recipient@example.com", - "Reply-To": "reply@example.com", - }), - MessageID: "invalid-message-id", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 0.7, - maxScore: 1.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := scorer.calculateHeaderScore(tt.email) - if score < tt.minScore || score > tt.maxScore { - t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - }) - } -} - -func TestDetermineRating(t *testing.T) { - tests := []struct { - name string - score float32 - expected string - }{ - {name: "Excellent - 10.0", score: 10.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 9.5, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 9.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 8.5, expected: "Good"}, - {name: "Good - 7.0", score: 7.0, expected: "Good"}, - {name: "Fair - 6.5", score: 6.5, expected: "Fair"}, - {name: "Fair - 5.0", score: 5.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 4.5, expected: "Poor"}, - {name: "Poor - 3.0", score: 3.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 2.5, expected: "Critical"}, - {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.determineRating(tt.score) - if result != tt.expected { - t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected) - } - }) - } -} - -func TestGetCategoryStatus(t *testing.T) { - tests := []struct { - name string - score float32 - maxScore float32 - expected string - }{ - {name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"}, - {name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"}, - {name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"}, - {name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"}, - {name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.getCategoryStatus(tt.score, tt.maxScore) - if result != tt.expected { - t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected) - } - }) - } -} - -func TestCalculateScore(t *testing.T) { - tests := []struct { - name string - authResults *api.AuthenticationResults - spamResult *SpamAssassinResult - rblResults *RBLResults - contentResults *ContentResults - email *EmailMessage - minScore float32 - maxScore float32 - expectedRating string - }{ - { - name: "Perfect email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{Result: api.AuthResultResultPass}, - }, - spamResult: &SpamAssassinResult{ - Score: -1.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextPlainRatio: 0.8, - ImageTextRatio: 3.0, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 9.0, - maxScore: 10.0, - expectedRating: "Excellent", - }, - { - name: "Poor email - auth issues", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultFail}, - Dkim: &[]api.AuthResult{}, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 8.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - { - IP: "192.0.2.1", - RBL: "zen.spamhaus.org", - Listed: true, - }, - }, - ListedCount: 1, - }, - contentResults: &ContentResults{ - HTMLValid: false, - Links: []LinkCheck{{Valid: true, Status: 404}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - minScore: 0.0, - maxScore: 5.0, - expectedRating: "Poor", - }, - { - name: "Average email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 4.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 6.0, - maxScore: 9.0, - expectedRating: "Good", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.CalculateScore( - tt.authResults, - tt.spamResult, - tt.rblResults, - tt.contentResults, - tt.email, - ) - - if result == nil { - t.Fatal("Expected result, got nil") - } - - // Check overall score - if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore { - t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore) - } - - // Check rating - if result.Rating != tt.expectedRating { - t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) - } - - // Verify score is within bounds - if result.OverallScore < 0.0 || result.OverallScore > 10.0 { - t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore) - } - - // Verify category breakdown exists - if len(result.CategoryBreakdown) != 5 { - t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown)) - } - - // Verify recommendations exist - if len(result.Recommendations) == 0 && result.Rating != "Excellent" { - t.Error("Expected recommendations for non-excellent rating") - } - - // Verify category scores add up to overall score - totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 { - t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)", - totalCategoryScore, result.OverallScore) - } - }) - } -} - -func TestGenerateRecommendations(t *testing.T) { - tests := []struct { - name string - result *ScoringResult - expectedMinCount int - shouldContainKeyword string - }{ - { - name: "Excellent - minimal recommendations", - result: &ScoringResult{ - OverallScore: 9.5, - Rating: "Excellent", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 2.0, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "Excellent", - }, - { - name: "Critical - many recommendations", - result: &ScoringResult{ - OverallScore: 1.0, - Rating: "Critical", - AuthScore: 0.5, - SpamScore: 0.0, - BlacklistScore: 0.0, - ContentScore: 0.3, - HeaderScore: 0.2, - }, - expectedMinCount: 5, - shouldContainKeyword: "Critical", - }, - { - name: "Poor authentication", - result: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "authentication", - }, - { - name: "Blacklist issues", - result: &ScoringResult{ - OverallScore: 4.0, - Rating: "Poor", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 0.5, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "blacklist", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recommendations := scorer.generateRecommendations(tt.result) - - if len(recommendations) < tt.expectedMinCount { - t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount) - } - - // Check if expected keyword appears in any recommendation - found := false - for _, rec := range recommendations { - if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) { - found = true - break - } - } - - if !found { - t.Errorf("No recommendation contains keyword %q. Recommendations: %v", - tt.shouldContainKeyword, recommendations) - } - }) - } -} - -func TestGenerateRequiredHeadersCheck(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "All required headers present", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - From: &mail.Address{Address: "sender@example.com"}, - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, - }, - { - name: "Missing all required headers", - email: &EmailMessage{ - Header: make(mail.Header), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "Missing some required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := scorer.generateRequiredHeadersCheck(tt.email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMessageIDCheck(t *testing.T) { - tests := []struct { - name string - messageID string - expectedStatus api.CheckStatus - }{ - { - name: "Valid Message-ID", - messageID: "", - expectedStatus: api.CheckStatusPass, - }, - { - name: "Invalid Message-ID format", - messageID: "invalid-message-id", - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Missing Message-ID", - messageID: "", - expectedStatus: api.CheckStatusFail, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Message-ID": tt.messageID, - }), - } - - check := scorer.generateMessageIDCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMIMEStructureCheck(t *testing.T) { - tests := []struct { - name string - parts []MessagePart - expectedStatus api.CheckStatus - }{ - { - name: "With MIME parts", - parts: []MessagePart{ - {ContentType: "text/plain", Content: "test"}, - {ContentType: "text/html", Content: "

test

"}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No MIME parts", - parts: []MessagePart{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - Parts: tt.parts, - } - - check := scorer.generateMIMEStructureCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateHeaderChecks(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minChecks int - }{ - { - name: "Nil email", - email: nil, - minChecks: 0, - }, - { - name: "Complete email", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minChecks: 4, // Required, Recommended, Message-ID, MIME - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := scorer.GenerateHeaderChecks(tt.email) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Headers category - for _, check := range checks { - if check.Category != api.Headers { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) - } - } - }) - } -} - -func TestGetScoreSummary(t *testing.T) { - result := &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - Recommendations: []string{ - "Improve content quality", - "Add more headers", - }, - } - - scorer := NewDeliverabilityScorer() - summary := scorer.GetScoreSummary(result) - - // Check that summary contains key information - if !strings.Contains(summary, "8.5") { - t.Error("Summary should contain overall score") - } - if !strings.Contains(summary, "Good") { - t.Error("Summary should contain rating") - } - if !strings.Contains(summary, "Authentication") { - t.Error("Summary should contain category names") - } - if !strings.Contains(summary, "Recommendations") { - t.Error("Summary should contain recommendations section") - } -} - -// Helper function to create mail.Header with specific fields -func createHeaderWithFields(fields map[string]string) mail.Header { - header := make(mail.Header) - for key, value := range fields { - if value != "" { - // Use canonical MIME header key format - canonicalKey := textproto.CanonicalMIMEHeaderKey(key) - header[canonicalKey] = []string{value} - } - } - return header -} diff --git a/internal/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go deleted file mode 100644 index 78a6a72..0000000 --- a/internal/analyzer/spamassassin.go +++ /dev/null @@ -1,340 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - "git.happydns.org/happyDeliver/internal/api" -) - -// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers -type SpamAssassinAnalyzer struct{} - -// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer -func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { - return &SpamAssassinAnalyzer{} -} - -// SpamAssassinResult represents parsed SpamAssassin results -type SpamAssassinResult struct { - IsSpam bool - Score float64 - RequiredScore float64 - Tests []string - TestDetails map[string]SpamTestDetail - Version string - RawReport string -} - -// SpamTestDetail contains details about a specific spam test -type SpamTestDetail struct { - Name string - Score float64 - Description string -} - -// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers -func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult { - headers := email.GetSpamAssassinHeaders() - if len(headers) == 0 { - return nil - } - - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), - } - - // Parse X-Spam-Status header - if statusHeader, ok := headers["X-Spam-Status"]; ok { - a.parseSpamStatus(statusHeader, result) - } - - // Parse X-Spam-Score header (as fallback if not in X-Spam-Status) - if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 { - if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { - result.Score = score - } - } - - // Parse X-Spam-Flag header (as fallback) - if flagHeader, ok := headers["X-Spam-Flag"]; ok { - result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES" - } - - // Parse X-Spam-Report header for detailed test results - if reportHeader, ok := headers["X-Spam-Report"]; ok { - result.RawReport = reportHeader - a.parseSpamReport(reportHeader, result) - } - - // Parse X-Spam-Checker-Version - if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok { - result.Version = strings.TrimSpace(versionHeader) - } - - return result -} - -// parseSpamStatus parses the X-Spam-Status header -// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no -func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) { - // Check if spam (first word) - parts := strings.SplitN(header, ",", 2) - if len(parts) > 0 { - firstPart := strings.TrimSpace(parts[0]) - result.IsSpam = strings.EqualFold(firstPart, "yes") - } - - // Extract score - scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`) - if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 { - if score, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.Score = score - } - } - - // Extract required score - requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`) - if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 { - if required, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.RequiredScore = required - } - } - - // Extract tests - testsRe := regexp.MustCompile(`tests=([^\s]+)`) - if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 { - testsStr := matches[1] - // Tests can be comma or space separated - tests := strings.FieldsFunc(testsStr, func(r rune) bool { - return r == ',' || r == ' ' - }) - result.Tests = tests - } -} - -// parseSpamReport parses the X-Spam-Report header to extract test details -// Format varies, but typically: -// * 1.5 TEST_NAME Description of test -// * 0.0 TEST_NAME2 Description -func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) { - // Split by lines - lines := strings.Split(report, "\n") - - // Regex to match test lines: * score TEST_NAME Description - testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - matches := testRe.FindStringSubmatch(line) - if len(matches) > 3 { - testName := matches[2] - score, _ := strconv.ParseFloat(matches[1], 64) - description := strings.TrimSpace(matches[3]) - - detail := SpamTestDetail{ - Name: testName, - Score: score, - Description: description, - } - result.TestDetails[testName] = detail - } - } -} - -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points) -// Scoring: -// - Score <= 0: 2 points (excellent) -// - Score < required: 1.5 points (good) -// - Score slightly above required (< 2x): 1 point (borderline) -// - Score moderately high (< 3x required): 0.5 points (poor) -// - Score very high: 0 points (spam) -func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { - if result == nil { - return 0.0 - } - - score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5.0 // Default SpamAssassin threshold - } - - // Calculate deliverability score - if score <= 0 { - return 2.0 - } else if score < required { - // Linear scaling from 1.5 to 2.0 based on how negative/low the score is - ratio := score / required - return 1.5 + (0.5 * (1.0 - float32(ratio))) - } else if score < required*2 { - // Slightly above threshold - return 1.0 - } else if score < required*3 { - // Moderately high - return 0.5 - } - - // Very high spam score - return 0.0 -} - -// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis -func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check { - var checks []api.Check - - if result == nil { - checks = append(checks, api.Check{ - Category: api.Spam, - Name: "SpamAssassin Analysis", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.Medium), - Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), - }) - return checks - } - - // Main spam score check - mainCheck := a.generateMainSpamCheck(result) - checks = append(checks, mainCheck) - - // Add checks for significant spam tests (score > 1.0 or < -1.0) - for _, test := range result.Tests { - if detail, ok := result.TestDetails[test]; ok { - if detail.Score > 1.0 || detail.Score < -1.0 { - check := a.generateTestCheck(detail) - checks = append(checks, check) - } - } - } - - return checks -} - -// generateMainSpamCheck creates the main spam score check -func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check { - check := api.Check{ - Category: api.Spam, - Name: "SpamAssassin Score", - } - - score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5.0 - } - - delivScore := a.GetSpamAssassinScore(result) - check.Score = delivScore - - // Determine status and message based on score - if score <= 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") - } else if score < required { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Info) - check.Advice = api.PtrTo("Your email passes spam filters") - } else if score < required*1.5 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Medium) - check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") - } else if score < required*2 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.High) - check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") - } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Critical) - check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") - } - - // Add details - if len(result.Tests) > 0 { - details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", ")) - if len(result.Tests) > 5 { - details += fmt.Sprintf(" and %d more", len(result.Tests)-5) - } - check.Details = &details - } - - return check -} - -// generateTestCheck creates a check for a specific spam test -func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check { - check := api.Check{ - Category: api.Spam, - Name: fmt.Sprintf("Spam Test: %s", detail.Name), - } - - if detail.Score > 0 { - // Negative indicator (increases spam score) - if detail.Score > 2.0 { - check.Status = api.CheckStatusFail - check.Severity = api.PtrTo(api.High) - } else { - check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.Medium) - } - check.Score = 0.0 - check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) - advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) - check.Advice = &advice - } else { - // Positive indicator (decreases spam score) - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) - check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) - advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) - check.Advice = &advice - } - - check.Details = &detail.Description - - return check -} - -// min returns the minimum of two integers -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 79d839e..470136e 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -32,20 +32,32 @@ import ( "git.happydns.org/happyDeliver/internal/config" "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 *DNSResults, score int, grade string) + CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error) +} + // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { storage storage.Storage config *config.Config + analyzer EmailAnalyzer startTime time.Time } // NewAPIHandler creates a new API handler -func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { +func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { return &APIHandler{ storage: store, config: cfg, + analyzer: analyzer, startTime: time.Now(), } } @@ -56,27 +68,41 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // Generate a unique test ID (no database record created) testID := uuid.New() - // Generate test email address + // Convert UUID to base32 string for the API response + base32ID := utils.UUIDToBase32(testID) + + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - testID.String(), + base32ID, h.config.Email.Domain, ) // Return response c.JSON(http.StatusCreated, TestResponse{ - Id: testID, + Id: base32ID, Email: openapi_types.Email(email), Status: TestResponseStatusPending, - Message: stringPtr("Send your test email to the address above"), + Message: stringPtr("Send your test email to the given address"), }) } // GetTest retrieves test metadata // (GET /test/{id}) -func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { +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{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + // Check if a report exists for this test ID - reportExists, err := h.storage.ReportExists(id) + reportExists, err := h.storage.ReportExists(testUUID) if err != nil { c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", @@ -94,29 +120,35 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { apiStatus = TestStatusPending } - // Generate test email address + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - id.String(), + id, h.config.Email.Domain, ) - // Return current time for CreatedAt/UpdatedAt since we don't track tests anymore - now := time.Now() - c.JSON(http.StatusOK, Test{ - Id: id, - Email: openapi_types.Email(email), - Status: apiStatus, - CreatedAt: now, - UpdatedAt: &now, + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, }) } // GetReport retrieves the detailed analysis report // (GET /report/{id}) -func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { - reportJSON, _, err := h.storage.GetReport(id) +func (h *APIHandler) GetReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { c.JSON(http.StatusNotFound, Error{ @@ -139,8 +171,19 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { // GetRawEmail retrieves the raw annotated email // (GET /report/{id}/raw) -func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { - _, rawEmail, err := h.storage.GetReport(id) +func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { c.JSON(http.StatusNotFound, Error{ @@ -160,6 +203,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { c.Data(http.StatusOK, "text/plain", rawEmail) } +// ReanalyzeReport re-analyzes an existing email and regenerates the report +// (POST /report/{id}/reanalyze) +func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + // Retrieve the existing report (mainly to get the raw email) + _, rawEmail, err := h.storage.GetReport(testUUID) + if err != nil { + if err == storage.ErrNotFound { + c.JSON(http.StatusNotFound, Error{ + Error: "not_found", + Message: "Email not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve email", + Details: stringPtr(err.Error()), + }) + return + } + + // Re-analyze the email using the current analyzer + reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "analysis_error", + Message: "Failed to re-analyze email", + Details: stringPtr(err.Error()), + }) + return + } + + // Update the report in storage + if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to update report", + Details: stringPtr(err.Error()), + }) + return + } + + // Return the updated report JSON directly + c.Data(http.StatusOK, "application/json", reportJSON) +} + // GetStatus retrieves service health status // (GET /status) func (h *APIHandler) GetStatus(c *gin.Context) { @@ -181,7 +281,7 @@ func (h *APIHandler) GetStatus(c *gin.Context) { mtaStatus := StatusComponentsMtaUp c.JSON(http.StatusOK, Status{ Status: overallStatus, - Version: "0.1.0-dev", + Version: version.Version, Components: &struct { Database *StatusComponentsDatabase `json:"database,omitempty"` Mta *StatusComponentsMta `json:"mta,omitempty"` @@ -192,3 +292,92 @@ 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 DomainTestRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: stringPtr(err.Error()), + }) + return + } + + // Perform domain analysis + dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) + + // Convert grade string to DomainTestResponseGrade enum + var responseGrade DomainTestResponseGrade + switch grade { + case "A+": + responseGrade = DomainTestResponseGradeA + case "A": + responseGrade = DomainTestResponseGradeA1 + case "B": + responseGrade = DomainTestResponseGradeB + case "C": + responseGrade = DomainTestResponseGradeC + case "D": + responseGrade = DomainTestResponseGradeD + case "E": + responseGrade = DomainTestResponseGradeE + case "F": + responseGrade = DomainTestResponseGradeF + default: + responseGrade = DomainTestResponseGradeF + } + + // Build response + response := 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 BlacklistCheckRequest + + // Bind and validate request + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_request", + Message: "Invalid request body", + Details: stringPtr(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, Error{ + Error: "invalid_ip", + Message: "Invalid IP address", + Details: stringPtr(err.Error()), + }) + return + } + + // Build response + response := BlacklistCheckResponse{ + Ip: request.Ip, + Blacklists: checks, + Whitelists: &whitelists, + ListedCount: listedCount, + Score: score, + Grade: BlacklistCheckResponseGrade(grade), + } + + c.JSON(http.StatusOK, response) +} diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 87a4e0a..d8336a5 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,9 +31,8 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" - "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // RunAnalyzer runs the standalone email analyzer (from stdin) @@ -87,57 +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")) - // Score summary - summary := emailAnalyzer.GetScoreSummaryText(result) - fmt.Fprintln(writer, summary) + // Score Summary + if report.Summary != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SCORE BREAKDOWN") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - // Detailed checks - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "DETAILED CHECK RESULTS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - // Group checks by category - categories := make(map[api.CheckCategory][]api.Check) - for _, check := range result.Report.Checks { - categories[check.Category] = append(categories[check.Category], check) + summary := report.Summary + fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", + summary.DnsScore, summary.DnsGrade) + fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", + summary.AuthenticationScore, summary.AuthenticationGrade) + fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", + summary.BlacklistScore, summary.BlacklistGrade) + fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", + summary.HeaderScore, summary.HeaderGrade) + fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", + summary.SpamScore, summary.SpamGrade) + fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", + summary.ContentScore, summary.ContentGrade) } - // Print checks by category - categoryOrder := []api.CheckCategory{ - api.Authentication, - api.Dns, - api.Blacklist, - api.Content, - api.Headers, - } + // DNS Results + if report.DnsResults != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DNS CONFIGURATION") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - for _, category := range categoryOrder { - checks, ok := categories[category] - if !ok || len(checks) == 0 { - continue + dns := report.DnsResults + fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) + if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { + fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) } - fmt.Fprintf(writer, "\n%s:\n", category) - for _, check := range checks { - statusSymbol := "✓" - if check.Status == api.CheckStatusFail { - statusSymbol = "✗" - } else if check.Status == api.CheckStatusWarn { - statusSymbol = "⚠" + // MX Records + if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { + fmt.Fprintln(writer, "\n MX Records (From Domain):") + for _, mx := range *dns.FromMxRecords { + status := "✓" + if !mx.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) + if mx.Error != nil { + fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) + } + fmt.Fprintln(writer) } + } - fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message) - if check.Advice != nil && *check.Advice != "" { - fmt.Fprintf(writer, " → %s\n", *check.Advice) + // SPF Records + if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { + fmt.Fprintln(writer, "\n SPF Records:") + for _, spf := range *dns.SpfRecords { + status := "✓" + if !spf.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s ", status) + if spf.Domain != nil { + fmt.Fprintf(writer, "Domain: %s", *spf.Domain) + } + if spf.AllQualifier != nil { + fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) + } + fmt.Fprintln(writer) + if spf.Record != nil { + fmt.Fprintf(writer, " %s\n", *spf.Record) + } + if spf.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) + } + } + } + + // DKIM Records + if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { + fmt.Fprintln(writer, "\n DKIM Records:") + for _, dkim := range *dns.DkimRecords { + status := "✓" + if !dkim.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) + if dkim.Record != nil { + fmt.Fprintf(writer, " %s\n", *dkim.Record) + } + if dkim.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) + } + } + } + + // DMARC Record + if dns.DmarcRecord != nil { + fmt.Fprintln(writer, "\n DMARC Record:") + status := "✓" + if !dns.DmarcRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) + if dns.DmarcRecord.Policy != nil { + fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) + } + if dns.DmarcRecord.SubdomainPolicy != nil { + fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) + } + fmt.Fprintln(writer) + if dns.DmarcRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) + } + if dns.DmarcRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) + } + } + + // BIMI Record + if dns.BimiRecord != nil { + fmt.Fprintln(writer, "\n BIMI Record:") + status := "✓" + if !dns.BimiRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", + status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) + if dns.BimiRecord.LogoUrl != nil { + fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) + } + if dns.BimiRecord.VmcUrl != nil { + fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) + } + if dns.BimiRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) + } + if dns.BimiRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) + } + } + + // PTR Records + if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { + fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") + for _, ptr := range *dns.PtrRecords { + fmt.Fprintf(writer, " %s\n", ptr) + } + } + + // DNS Errors + if dns.Errors != nil && len(*dns.Errors) > 0 { + fmt.Fprintln(writer, "\n DNS Errors:") + for _, err := range *dns.Errors { + fmt.Fprintf(writer, " ! %s\n", err) } } } + // Authentication Results + if report.Authentication != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "AUTHENTICATION RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + auth := report.Authentication + + // SPF + if auth.Spf != nil { + fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) + if auth.Spf.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) + } + if auth.Spf.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) + } + fmt.Fprintln(writer) + } + + // DKIM + if auth.Dkim != nil && len(*auth.Dkim) > 0 { + fmt.Fprintln(writer, "\n DKIM:") + for i, dkim := range *auth.Dkim { + fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) + if dkim.Domain != nil { + fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) + if dkim.Selector != nil { + fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) + } + fmt.Fprintf(writer, ")") + } + if dkim.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) + } + fmt.Fprintln(writer) + } + } + + // DMARC + if auth.Dmarc != nil { + fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) + if auth.Dmarc.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) + } + if auth.Dmarc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) + } + fmt.Fprintln(writer) + } + + // ARC + if auth.Arc != nil { + fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) + if auth.Arc.ChainLength != nil { + fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) + } + if auth.Arc.ChainValid != nil { + fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) + } + if auth.Arc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) + } + fmt.Fprintln(writer) + } + + // BIMI + if auth.Bimi != nil { + fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) + if auth.Bimi.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) + } + if auth.Bimi.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) + } + fmt.Fprintln(writer) + } + + // IP Reverse + if auth.Iprev != nil { + fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) + if auth.Iprev.Ip != nil { + fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) + if auth.Iprev.Hostname != nil { + fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) + } + fmt.Fprintf(writer, ")") + } + if auth.Iprev.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) + } + fmt.Fprintln(writer) + } + } + + // Blacklist Results + if report.Blacklists != nil && len(*report.Blacklists) > 0 { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "BLACKLIST CHECKS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + totalChecks := 0 + totalListed := 0 + for ip, checks := range *report.Blacklists { + totalChecks += len(checks) + fmt.Fprintf(writer, "\n IP Address: %s\n", ip) + for _, check := range checks { + status := "✓" + if check.Listed { + status = "✗" + totalListed++ + } + fmt.Fprintf(writer, " %s %s", status, check.Rbl) + if check.Listed { + fmt.Fprintf(writer, " - LISTED") + if check.Response != nil { + fmt.Fprintf(writer, " (%s)", *check.Response) + } + } else { + fmt.Fprintf(writer, " - OK") + } + fmt.Fprintln(writer) + if check.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) + } + } + } + fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) + } + + // Header Analysis + if report.HeaderAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "HEADER ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + header := report.HeaderAnalysis + + // Domain Alignment + if header.DomainAlignment != nil { + fmt.Fprintln(writer, "\n Domain Alignment:") + align := header.DomainAlignment + if align.FromDomain != nil { + fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) + if align.FromOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) + } + fmt.Fprintln(writer) + } + if align.ReturnPathDomain != nil { + fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) + if align.ReturnPathOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) + } + fmt.Fprintln(writer) + } + if align.Aligned != nil { + fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) + } + if align.RelaxedAligned != nil { + fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) + } + if align.Issues != nil && len(*align.Issues) > 0 { + fmt.Fprintln(writer, " Issues:") + for _, issue := range *align.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + + // Required/Important Headers + if header.Headers != nil { + fmt.Fprintln(writer, "\n Standard Headers:") + importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} + for _, hdrName := range importantHeaders { + if hdr, ok := (*header.Headers)[hdrName]; ok { + status := "✗" + if hdr.Present { + status = "✓" + } + fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) + if hdr.Present { + if hdr.Valid != nil && !*hdr.Valid { + fmt.Fprintf(writer, "INVALID") + } else { + fmt.Fprintf(writer, "OK") + } + if hdr.Importance != nil { + fmt.Fprintf(writer, " [%s]", *hdr.Importance) + } + } else { + fmt.Fprintf(writer, "MISSING") + } + fmt.Fprintln(writer) + if hdr.Issues != nil && len(*hdr.Issues) > 0 { + for _, issue := range *hdr.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + } + } + + // Header Issues + if header.Issues != nil && len(*header.Issues) > 0 { + fmt.Fprintln(writer, "\n Header Issues:") + for _, issue := range *header.Issues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + + // Received Chain + if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { + fmt.Fprintln(writer, "\n Email Path (Received Chain):") + for i, hop := range *header.ReceivedChain { + fmt.Fprintf(writer, " [%d] ", i+1) + if hop.From != nil { + fmt.Fprintf(writer, "%s", *hop.From) + if hop.Ip != nil { + fmt.Fprintf(writer, " (%s)", *hop.Ip) + } + } + if hop.By != nil { + fmt.Fprintf(writer, " -> %s", *hop.By) + } + fmt.Fprintln(writer) + if hop.Timestamp != nil { + fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) + } + } + } + } + + // SpamAssassin Results + if report.Spamassassin != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + sa := report.Spamassassin + fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) + if sa.IsSpam { + fmt.Fprintf(writer, " (SPAM)") + } else { + fmt.Fprintf(writer, " (HAM)") + } + fmt.Fprintln(writer) + + if sa.Version != nil { + fmt.Fprintf(writer, " Version: %s\n", *sa.Version) + } + + if len(sa.TestDetails) > 0 { + fmt.Fprintln(writer, "\n Triggered Tests:") + for _, test := range sa.TestDetails { + scoreStr := "+" + if test.Score < 0 { + scoreStr = "" + } + fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) + if test.Description != nil { + fmt.Fprintf(writer, "\n %s", *test.Description) + } + fmt.Fprintln(writer) + } + } + } + + // Content Analysis + if report.ContentAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "CONTENT ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + content := report.ContentAnalysis + + // Basic content info + fmt.Fprintln(writer, "\n Content Structure:") + if content.HasPlaintext != nil { + fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) + } + if content.HasHtml != nil { + fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) + } + if content.TextToImageRatio != nil { + fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) + } + + // Unsubscribe + if content.HasUnsubscribeLink != nil { + fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) + if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { + fmt.Fprintf(writer, " Unsubscribe Methods: ") + for i, method := range *content.UnsubscribeMethods { + if i > 0 { + fmt.Fprintf(writer, ", ") + } + fmt.Fprintf(writer, "%s", method) + } + fmt.Fprintln(writer) + } + } + + // Links + if content.Links != nil && len(*content.Links) > 0 { + fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) + for _, link := range *content.Links { + status := "" + switch link.Status { + case "valid": + status = "✓" + case "broken": + status = "✗" + case "suspicious": + status = "⚠" + case "redirected": + status = "→" + case "timeout": + status = "⏱" + } + fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) + if link.HttpCode != nil { + fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) + } + fmt.Fprintln(writer) + if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { + fmt.Fprintln(writer, " Redirect chain:") + for _, url := range *link.RedirectChain { + fmt.Fprintf(writer, " -> %s\n", url) + } + } + } + } + + // Images + if content.Images != nil && len(*content.Images) > 0 { + fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) + missingAlt := 0 + trackingPixels := 0 + for _, img := range *content.Images { + if !img.HasAlt { + missingAlt++ + } + if img.IsTrackingPixel != nil && *img.IsTrackingPixel { + trackingPixels++ + } + } + fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", + len(*content.Images)-missingAlt, len(*content.Images)) + if trackingPixels > 0 { + fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) + } + } + + // HTML Issues + if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { + fmt.Fprintln(writer, "\n Content Issues:") + for _, issue := range *content.HtmlIssues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) + if issue.Location != nil { + fmt.Fprintf(writer, " Location: %s\n", *issue.Location) + } + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + } + + // Footer fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + return nil } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..4b01fbb --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,156 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + fmt.Fprintln(os.Stderr, "Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) + fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) + failed++ + continue + } + + if exists { + fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) + failed++ + continue + } + + restored++ + } + + fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go index 332516b..7149f45 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -25,13 +25,16 @@ import ( "context" "log" "os" + "time" + ratelimit "github.com/JGLTechnologies/gin-rate-limit" "github.com/gin-gonic/gin" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" "git.happydns.org/happyDeliver/web" ) @@ -63,8 +66,11 @@ func RunServer(cfg *config.Config) error { } }() + // Create analyzer adapter for API + analyzerAdapter := analyzer.NewAPIAdapter(cfg) + // Create API handler - handler := api.NewAPIHandler(store, cfg) + handler := api.NewAPIHandler(store, cfg, analyzerAdapter) // Set up Gin router if os.Getenv("GIN_MODE") == "" { @@ -72,8 +78,30 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - // Register API routes apiGroup := router.Group("/api") + + if cfg.RateLimit > 0 { + // Set up rate limiting (2x to handle burst) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 2 * time.Second, + Limit: 2 * cfg.RateLimit, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + apiGroup.Use(rateLimiter) + } + + // Register API routes api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) diff --git a/internal/config/cli.go b/internal/config/cli.go index 93c18ce..3accc99 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -37,7 +37,11 @@ func declareFlags(o *Config) { 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.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") // 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 d59045b..468a2aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "flag" "fmt" "log" + "net/url" "os" "path" "strings" @@ -41,6 +42,9 @@ 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 } // DatabaseConfig contains database connection settings @@ -58,17 +62,20 @@ type EmailConfig struct { // 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 } // DefaultConfig returns a configuration with sensible defaults func DefaultConfig() *Config { return &Config{ DevProxy: "", - Bind: ":8081", + 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", @@ -82,6 +89,8 @@ func DefaultConfig() *Config { DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, + DNSWLs: []string{}, + CheckAllIPs: false, // By default, only check the first IP }, } } @@ -113,7 +122,7 @@ func ConsolidateConfig() (opts *Config, err error) { // If config file exists, read configuration from it for _, filename := range configLocations { - if _, e := os.Stat(filename); !os.IsNotExist(e) { + if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) { log.Printf("Loading configuration from %s\n", filename) err = parseFile(opts, filename) if err != nil { diff --git a/internal/config/custom.go b/internal/config/custom.go index 9461632..97c8d71 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -23,6 +23,7 @@ package config import ( "fmt" + "net/url" "strings" ) @@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error { return nil } + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + *i.URL = *u + return nil +} diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go index 1d9a720..a9b36b9 100644 --- a/internal/lmtp/server.go +++ b/internal/lmtp/server.go @@ -92,6 +92,10 @@ func (s *Session) Data(r io.Reader) error { log.Printf("LMTP: Received %d bytes", len(emailData)) + // Prepend Return-Path header from envelope sender + returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from) + emailData = append([]byte(returnPath), emailData...) + // Process email for each recipient // LMTP requires per-recipient status, but go-smtp handles this internally for _, recipient := range s.recipients { diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index db1c2ea..062a091 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,6 +22,7 @@ package receiver import ( + "encoding/base32" "encoding/json" "fmt" "io" @@ -31,9 +32,9 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // EmailReceiver handles incoming emails from the MTA @@ -95,7 +96,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return fmt.Errorf("failed to analyze email: %w", err) } - log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) + log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) @@ -112,8 +113,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return nil } +// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID +// Hyphens are ignored during decoding +func base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens for decoding + encoded = strings.ReplaceAll(encoded, "-", "") + + // Convert to uppercase for Base32 decoding + encoded = strings.ToUpper(encoded) + + // Decode from Base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err) + } + + // Ensure we have exactly 16 bytes for UUID + if len(decoded) != 16 { + return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded)) + } + + // Convert bytes to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} + // extractTestID extracts the UUID from the test email address -// Expected format: test-@domain.com +// Expected format: test-@domain.com func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { // Remove angle brackets if present (e.g., ) email = strings.Trim(email, "<>") @@ -133,10 +160,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix) - // Parse UUID - testID, err := uuid.Parse(uuidStr) + // Decode Base32 to UUID + testID, err := base32ToUUID(uuidStr) if err != nil { - return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr) + return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err) } return testID, nil diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7c27279..39b2eb6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -43,6 +43,7 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) + UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) // Close closes the database connection @@ -107,7 +108,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) { // GetReport retrieves a report by test ID, returning the raw JSON and email bytes func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { var dbReport Report - if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil { + if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrNotFound } @@ -117,6 +118,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { return dbReport.ReportJSON, dbReport.RawEmail, nil } +// UpdateReport updates the report JSON for an existing test ID +func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error { + result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON) + if result.Error != nil { + return fmt.Errorf("failed to update report: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + // DeleteOldReports deletes reports older than the specified time func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) @@ -134,3 +147,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/utils/uuid.go b/internal/utils/uuid.go new file mode 100644 index 0000000..ebbbbdf --- /dev/null +++ b/internal/utils/uuid.go @@ -0,0 +1,75 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package utils + +import ( + "encoding/base32" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding) +// with hyphens every 7 characters for better readability +func UUIDToBase32(id uuid.UUID) string { + // Use RFC 4648 Base32 encoding (URL-safe) + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:]) + // Convert to lowercase for better readability + encoded = strings.ToLower(encoded) + + // Insert hyphens every 7 characters + var result strings.Builder + for i, char := range encoded { + if i > 0 && i%7 == 0 { + result.WriteRune('-') + } + result.WriteRune(char) + } + + return result.String() +} + +// Base32ToUUID converts a base32-encoded string back to a UUID +// Accepts strings with or without hyphens +func Base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens + encoded = strings.ReplaceAll(encoded, "-", "") + // Convert to uppercase for decoding + encoded = strings.ToUpper(encoded) + + // Decode base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err) + } + + // Ensure we have exactly 16 bytes for a UUID + if len(decoded) != 16 { + return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded)) + } + + // Convert byte slice to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a46c79f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,26 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package version + +// Version is the application version. It can be set at build time using ldflags: +// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3" +var Version = "(custom build)" diff --git a/internal/analyzer/analyzer.go b/pkg/analyzer/analyzer.go similarity index 51% rename from internal/analyzer/analyzer.go rename to pkg/analyzer/analyzer.go index 3588280..a16829b 100644 --- a/internal/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -23,6 +23,7 @@ package analyzer import ( "bytes" + "encoding/json" "fmt" "github.com/google/uuid" @@ -43,6 +44,8 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, + cfg.Analysis.CheckAllIPs, ) return &EmailAnalyzer{ @@ -78,10 +81,68 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A }, nil } -// GetScoreSummaryText returns a human-readable score summary -func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { - if result == nil || result.Results == nil { - return "" - } - return a.generator.GetScoreSummaryText(result.Results) +// APIAdapter adapts the EmailAnalyzer to work with the API package +// This adapter implements the interface expected by the API handler +type APIAdapter struct { + analyzer *EmailAnalyzer +} + +// NewAPIAdapter creates a new API adapter for the email analyzer +func NewAPIAdapter(cfg *config.Config) *APIAdapter { + return &APIAdapter{ + analyzer: NewEmailAnalyzer(cfg), + } +} + +// AnalyzeEmailBytes performs analysis and returns JSON bytes directly +func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { + result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return nil, err + } + + // Marshal report to JSON + reportJSON, err := json.Marshal(result.Report) + if err != nil { + return nil, fmt.Errorf("failed to marshal report: %w", err) + } + + return reportJSON, nil +} + +// AnalyzeDomain performs DNS analysis for a domain and returns the results +func (a *APIAdapter) AnalyzeDomain(domain string) (*api.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) ([]api.BlacklistCheck, []api.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][]api.BlacklistCheck{ip: checks}, + IPsChecked: []string{ip}, + ListedCount: listedCount, + } + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results) + + // 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 new file mode 100644 index 0000000..07f7794 --- /dev/null +++ b/pkg/analyzer/authentication.go @@ -0,0 +1,180 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// AuthenticationAnalyzer analyzes email authentication results +type AuthenticationAnalyzer struct{} + +// NewAuthenticationAnalyzer creates a new authentication analyzer +func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{} +} + +// AnalyzeAuthentication extracts and analyzes authentication results from email headers +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { + results := &api.AuthenticationResults{} + + // Parse Authentication-Results headers + authHeaders := email.GetAuthenticationResults() + for _, header := range authHeaders { + a.parseAuthenticationResultsHeader(header, results) + } + + // If no Authentication-Results headers, try to parse legacy headers + if results.Spf == nil { + results.Spf = a.parseLegacySPF(email) + } + + // Parse ARC headers if not already parsed from Authentication-Results + if results.Arc == nil { + results.Arc = a.parseARCHeaders(email) + } else { + // Enhance the ARC result with chain information from raw headers + a.enhanceARCResult(email, results.Arc) + } + + return results +} + +// 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) { + // Split by semicolon to get individual results + parts := strings.Split(header, ";") + if len(parts) < 2 { + return + } + + // Skip the authserv-id (first part) + for i := 1; i < len(parts); i++ { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + + // Parse SPF + if strings.HasPrefix(part, "spf=") { + if results.Spf == nil { + results.Spf = a.parseSPFResult(part) + } + } + + // Parse DKIM + if strings.HasPrefix(part, "dkim=") { + dkimResult := a.parseDKIMResult(part) + if dkimResult != nil { + if results.Dkim == nil { + dkimList := []api.AuthResult{*dkimResult} + results.Dkim = &dkimList + } else { + *results.Dkim = append(*results.Dkim, *dkimResult) + } + } + } + + // Parse DMARC + if strings.HasPrefix(part, "dmarc=") { + if results.Dmarc == nil { + results.Dmarc = a.parseDMARCResult(part) + } + } + + // Parse BIMI + if strings.HasPrefix(part, "bimi=") { + if results.Bimi == nil { + results.Bimi = a.parseBIMIResult(part) + } + } + + // Parse ARC + if strings.HasPrefix(part, "arc=") { + if results.Arc == nil { + results.Arc = a.parseARCResult(part) + } + } + + // Parse IPRev + if strings.HasPrefix(part, "iprev=") { + if results.Iprev == nil { + results.Iprev = a.parseIPRevResult(part) + } + } + + // Parse x-google-dkim + if strings.HasPrefix(part, "x-google-dkim=") { + if results.XGoogleDkim == nil { + results.XGoogleDkim = a.parseXGoogleDKIMResult(part) + } + } + + // Parse x-aligned-from + if strings.HasPrefix(part, "x-aligned-from=") { + if results.XAlignedFrom == nil { + results.XAlignedFrom = a.parseXAlignedFromResult(part) + } + } + } +} + +// CalculateAuthenticationScore calculates the authentication score from auth results +// Returns a score from 0-100 where higher is better +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { + if results == nil { + return 0, "" + } + + score := 0 + + // IPRev (15 points) + score += 15 * a.calculateIPRevScore(results) / 100 + + // SPF (25 points) + score += 25 * a.calculateSPFScore(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 + + // BIMI (10 points) + score += 10 * a.calculateBIMIScore(results) / 100 + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + + return score, ScoreToGrade(score) +} diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go new file mode 100644 index 0000000..01b7505 --- /dev/null +++ b/pkg/analyzer/authentication_arc.go @@ -0,0 +1,183 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.ARCResult{} + + // Extract result (pass, fail, none) + re := regexp.MustCompile(`arc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.ARCResultResult(resultStr) + } + + result.Details = api.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 { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &api.ARCResult{ + Result: api.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = api.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = api.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = api.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go new file mode 100644 index 0000000..9269d70 --- /dev/null +++ b/pkg/analyzer/authentication_arc_test.go @@ -0,0 +1,150 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.ARCResultResult + }{ + { + name: "ARC pass", + part: "arc=pass", + expectedResult: api.ARCResultResultPass, + }, + { + name: "ARC fail", + part: "arc=fail", + expectedResult: api.ARCResultResultFail, + }, + { + name: "ARC none", + part: "arc=none", + expectedResult: api.ARCResultResultNone, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + }) + } +} + +func TestValidateARCChain(t *testing.T) { + tests := []struct { + name string + arcAuthResults []string + arcMessageSig []string + arcSeal []string + expectedValid bool + }{ + { + name: "Empty chain is valid", + arcAuthResults: []string{}, + arcMessageSig: []string{}, + arcSeal: []string{}, + expectedValid: true, + }, + { + name: "Valid chain with single hop", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + }, + expectedValid: true, + }, + { + name: "Valid chain with two hops", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=2; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=2; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=2; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: true, + }, + { + name: "Invalid chain - missing one header type", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{}, + expectedValid: false, + }, + { + name: "Invalid chain - non-sequential instances", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=3; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=3; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=3; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: false, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) + + if valid != tt.expectedValid { + t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) + } + }) + } +} diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go new file mode 100644 index 0000000..0d68281 --- /dev/null +++ b/pkg/analyzer/authentication_bimi.go @@ -0,0 +1,75 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { + if results.Bimi != nil { + switch results.Bimi.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultDeclined: + return 59 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go new file mode 100644 index 0000000..b1b5468 --- /dev/null +++ b/pkg/analyzer/authentication_bimi_test.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "BIMI pass with domain and selector", + part: "bimi=pass header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI with short form (d= and selector=)", + part: "bimi=pass d=example.com selector=v1", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: api.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go new file mode 100644 index 0000000..b6cf5f8 --- /dev/null +++ b/pkg/analyzer/authentication_dkim.go @@ -0,0 +1,86 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDKIMResult parses DKIM result from Authentication-Results +// Example: dkim=pass header.d=example.com header.s=selector1 +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.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 { + hasPass = true + } else { + hasNonPass = true + } + } + if hasPass && hasNonPass { + // Could be better + return 90 + } else if hasPass { + return 100 + } else { + // Has DKIM signatures but none passed + return 20 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go new file mode 100644 index 0000000..2aab530 --- /dev/null +++ b/pkg/analyzer/authentication_dkim_test.go @@ -0,0 +1,86 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "DKIM pass with domain and selector", + part: "dkim=pass header.d=example.com header.s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "DKIM fail", + part: "dkim=fail header.d=example.com header.s=selector1", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "selector1", + }, + { + name: "DKIM with short form (d= and s=)", + part: "dkim=pass d=example.com s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + }) + } +} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go new file mode 100644 index 0000000..329a5c9 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc.go @@ -0,0 +1,68 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDMARCResult parses DMARC result from Authentication-Results +// Example: dmarc=pass action=none header.from=example.com +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dmarc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNone: + return 33 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go new file mode 100644 index 0000000..d7fda84 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -0,0 +1,69 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go new file mode 100644 index 0000000..6538cbb --- /dev/null +++ b/pkg/analyzer/authentication_iprev.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// 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{} + + // 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) + } + + // Extract IP address (smtp.remote-ip or remote-ip) + ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) + if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { + ip := matches[1] + result.Ip = &ip + } + + // Extract hostname from parentheses + hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) + if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { + hostname := matches[1] + result.Hostname = &hostname + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { + if results.Iprev != nil { + switch results.Iprev.Result { + case api.Pass: + return 100 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go new file mode 100644 index 0000000..d0529b5 --- /dev/null +++ b/pkg/analyzer/authentication_iprev_test.go @@ -0,0 +1,225 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseIPRevResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.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"), + }, + { + 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"), + }, + { + 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"), + }, + { + name: "IPRev temperror", + part: "iprev=temperror smtp.remote-ip=203.0.113.1", + expectedResult: api.Temperror, + expectedIP: api.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"), + 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"), + }, + { + 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"), + }, + { + 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"), + expectedHostname: nil, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseIPRevResult(tt.part) + + // Check result + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + // Check IP + if tt.expectedIP != nil { + if result.Ip == nil { + t.Errorf("IP = nil, want %v", *tt.expectedIP) + } else if *result.Ip != *tt.expectedIP { + t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) + } + } else { + if result.Ip != nil { + t.Errorf("IP = %v, want nil", *result.Ip) + } + } + + // Check hostname + if tt.expectedHostname != nil { + if result.Hostname == nil { + t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) + } else if *result.Hostname != *tt.expectedHostname { + t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) + } + } else { + if result.Hostname != nil { + t.Errorf("Hostname = %v, want nil", *result.Hostname) + } + } + + // Check details + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } + }) + } +} + +func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { + tests := []struct { + name string + header string + expectedIPRevResult *api.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"), + }, + { + 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"), + }, + { + 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"), + expectedHostname: nil, + }, + { + name: "No IPRev in header", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", + expectedIPRevResult: nil, + }, + { + name: "Multiple IPRev results - only first is parsed", + header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("first.com"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check IPRev + if tt.expectedIPRevResult != nil { + if results.Iprev == nil { + t.Errorf("Expected IPRev result, got nil") + } else { + if results.Iprev.Result != *tt.expectedIPRevResult { + t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) + } + if tt.expectedIP != nil { + if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { + var gotIP string + if results.Iprev.Ip != nil { + gotIP = *results.Iprev.Ip + } + t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) + } + } + if tt.expectedHostname != nil { + if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { + var gotHostname string + if results.Iprev.Hostname != nil { + gotHostname = *results.Iprev.Hostname + } + t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) + } + } + } + } else { + if results.Iprev != nil { + t.Errorf("Expected no IPRev result, got %+v", results.Iprev) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go new file mode 100644 index 0000000..479c325 --- /dev/null +++ b/pkg/analyzer/authentication_spf.go @@ -0,0 +1,105 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseSPFResult parses SPF result from Authentication-Results +// Example: spf=pass smtp.mailfrom=sender@example.com +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`spf=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + result := &api.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = api.AuthResultResult(resultStr) + } + + result.Details = &receivedSPF + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNeutral, api.AuthResultResultNone: + return 50 + case api.AuthResultResultSoftfail: + return 17 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go new file mode 100644 index 0000000..7a84c49 --- /dev/null +++ b/pkg/analyzer/authentication_spf_test.go @@ -0,0 +1,212 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseLegacySPF(t *testing.T) { + tests := []struct { + name string + receivedSPF string + expectedResult api.AuthResultResult + expectedDomain *string + expectNil bool + }{ + { + name: "SPF pass with envelope-from", + receivedSPF: `pass + (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) + receiver=mx.receiver.com; + identity=mailfrom; + envelope-from="user@example.com"; + helo=smtp.example.com; + client-ip=192.0.2.10`, + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("example.com"), + }, + { + name: "SPF fail with sender", + receivedSPF: `fail + (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) + receiver=mx.receiver.com; + identity=mailfrom; + sender="sender@test.com"; + helo=smtp.test.com; + client-ip=192.0.2.20`, + expectedResult: api.AuthResultResultFail, + expectedDomain: api.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"), + }, + { + 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"), + }, + { + 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"), + }, + { + 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"), + }, + { + 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"), + }, + { + name: "SPF pass without domain extraction", + receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", + expectedResult: api.AuthResultResultPass, + expectedDomain: nil, + }, + { + name: "Empty Received-SPF header", + receivedSPF: "", + expectNil: true, + }, + { + name: "SPF with unquoted envelope-from", + receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("mail.example.net"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with Received-SPF header + email := &EmailMessage{ + Header: make(map[string][]string), + } + if tt.receivedSPF != "" { + email.Header["Received-Spf"] = []string{tt.receivedSPF} + } + + result := analyzer.parseLegacySPF(email) + + if tt.expectNil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result, got nil") + } + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if tt.expectedDomain != nil { + if result.Domain == nil { + t.Errorf("Domain = nil, want %v", *tt.expectedDomain) + } else if *result.Domain != *tt.expectedDomain { + t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) + } + } else { + if result.Domain != nil { + t.Errorf("Domain = %v, want nil", *result.Domain) + } + } + + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } else if *result.Details != tt.receivedSPF { + t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) + } + }) + } +} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go new file mode 100644 index 0000000..27901b5 --- /dev/null +++ b/pkg/analyzer/authentication_test.go @@ -0,0 +1,438 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestGetAuthenticationScore(t *testing.T) { + tests := []struct { + name string + results *api.AuthenticationResults + expectedScore int + }{ + { + name: "Perfect authentication (SPF + DKIM + DMARC)", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 + }, + { + name: "SPF and DKIM only", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 48, // SPF=25 + DKIM=23 + }, + { + name: "SPF fail, DKIM pass", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultFail, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 23, // SPF=0 + DKIM=23 + }, + { + name: "SPF softfail", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, + }, + }, + expectedScore: 4, + }, + { + name: "No authentication", + results: &api.AuthenticationResults{}, + expectedScore: 0, + }, + { + name: "BIMI adds to score", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedScore: 35, // SPF (25) + BIMI (10) + }, + } + + scorer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, _ := scorer.CalculateAuthenticationScore(tt.results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} + +func TestParseAuthenticationResultsHeader(t *testing.T) { + tests := []struct { + name string + header string + expectedSPFResult *api.AuthResultResult + expectedSPFDomain *string + expectedDKIMCount int + expectedDKIMResult *api.AuthResultResult + expectedDMARCResult *api.AuthResultResult + expectedDMARCDomain *string + expectedBIMIResult *api.AuthResultResult + expectedARCResult *api.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"), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCDomain: api.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"), + expectedDKIMCount: 0, + expectedDMARCResult: nil, + }, + { + name: "DKIM only", + header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.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), + 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"), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.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"), + 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), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), + expectedDMARCDomain: api.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"), + expectedDKIMCount: 0, + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + }, + { + name: "ARC pass", + header: "mail.example.com; arc=pass", + expectedSPFResult: nil, + expectedDKIMCount: 0, + expectedARCResult: api.PtrTo(api.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"), + 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), + }, + { + name: "Empty header (authserv-id only)", + header: "mx.google.com", + expectedSPFResult: nil, + expectedDKIMCount: 0, + }, + { + name: "Empty parts with semicolons", + header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "DKIM with short form parameters", + header: "mail.example.com; dkim=pass d=example.com s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.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"), + expectedDKIMCount: 0, + }, + { + name: "SPF none", + header: "mail.example.com; spf=none", + expectedSPFResult: api.PtrTo(api.AuthResultResultNone), + expectedDKIMCount: 0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check SPF + if tt.expectedSPFResult != nil { + if results.Spf == nil { + t.Errorf("Expected SPF result, got nil") + } else { + if results.Spf.Result != *tt.expectedSPFResult { + t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult) + } + if tt.expectedSPFDomain != nil { + if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain { + var gotDomain string + if results.Spf.Domain != nil { + gotDomain = *results.Spf.Domain + } + t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain) + } + } + } + } else { + if results.Spf != nil { + t.Errorf("Expected no SPF result, got %+v", results.Spf) + } + } + + // Check DKIM count and result + if results.Dkim == nil { + if tt.expectedDKIMCount != 0 { + t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount) + } + } else { + if len(*results.Dkim) != tt.expectedDKIMCount { + t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount) + } + if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 { + if (*results.Dkim)[0].Result != *tt.expectedDKIMResult { + t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult) + } + } + } + + // Check DMARC + if tt.expectedDMARCResult != nil { + if results.Dmarc == nil { + t.Errorf("Expected DMARC result, got nil") + } else { + if results.Dmarc.Result != *tt.expectedDMARCResult { + t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult) + } + if tt.expectedDMARCDomain != nil { + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain { + var gotDomain string + if results.Dmarc.Domain != nil { + gotDomain = *results.Dmarc.Domain + } + t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain) + } + } + } + } else { + if results.Dmarc != nil { + t.Errorf("Expected no DMARC result, got %+v", results.Dmarc) + } + } + + // Check BIMI + if tt.expectedBIMIResult != nil { + if results.Bimi == nil { + t.Errorf("Expected BIMI result, got nil") + } else { + if results.Bimi.Result != *tt.expectedBIMIResult { + t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult) + } + } + } else { + if results.Bimi != nil { + t.Errorf("Expected no BIMI result, got %+v", results.Bimi) + } + } + + // Check ARC + if tt.expectedARCResult != nil { + if results.Arc == nil { + t.Errorf("Expected ARC result, got nil") + } else { + if results.Arc.Result != *tt.expectedARCResult { + t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult) + } + } + } else { + if results.Arc != nil { + t.Errorf("Expected no ARC result, got %+v", results.Arc) + } + } + }) + } +} + +func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { + // This test verifies that only the first occurrence of each auth method is parsed + 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{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Spf == nil { + t.Fatal("Expected SPF result, got nil") + } + if results.Spf.Result != api.AuthResultResultPass { + t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) + } + if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { + t.Errorf("Expected domain from first SPF result") + } + }) + + 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{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dmarc == nil { + t.Fatal("Expected DMARC result, got nil") + } + if results.Dmarc.Result != api.AuthResultResultPass { + t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) + } + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { + t.Errorf("Expected domain from first DMARC result") + } + }) + + t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; arc=pass; arc=fail" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Arc == nil { + t.Fatal("Expected ARC result, got nil") + } + if results.Arc.Result != api.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{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Bimi == nil { + t.Fatal("Expected BIMI result, got nil") + } + if results.Bimi.Result != api.AuthResultResultPass { + t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) + } + if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { + t.Errorf("Expected domain from first BIMI result") + } + }) + + t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { + // DKIM is special - multiple signatures should all be collected + header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 2 { + t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultPass { + t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[1].Result != api.AuthResultResultFail { + t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) + } + }) +} diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go new file mode 100644 index 0000000..36da2b0 --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -0,0 +1,65 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// 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{} + + // 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) + } + + // Extract details (everything after the result) + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { + if results.XAlignedFrom != nil { + switch results.XAlignedFrom.Result { + case api.AuthResultResultPass: + // pass: positive contribution + return 100 + case api.AuthResultResultFail: + // fail: negative contribution + return 0 + default: + // neutral, none, etc.: no impact + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go new file mode 100644 index 0000000..220ac39 --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -0,0 +1,144 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseXAlignedFromResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDetail string + }{ + { + name: "x-aligned-from pass with details", + part: "x-aligned-from=pass (Address match)", + expectedResult: api.AuthResultResultPass, + expectedDetail: "pass (Address match)", + }, + { + name: "x-aligned-from fail with reason", + part: "x-aligned-from=fail (Address mismatch)", + expectedResult: api.AuthResultResultFail, + expectedDetail: "fail (Address mismatch)", + }, + { + name: "x-aligned-from pass minimal", + part: "x-aligned-from=pass", + expectedResult: api.AuthResultResultPass, + expectedDetail: "pass", + }, + { + name: "x-aligned-from neutral", + part: "x-aligned-from=neutral (No alignment check performed)", + expectedResult: api.AuthResultResultNeutral, + expectedDetail: "neutral (No alignment check performed)", + }, + { + name: "x-aligned-from none", + part: "x-aligned-from=none", + expectedResult: api.AuthResultResultNone, + expectedDetail: "none", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXAlignedFromResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if result.Details == nil { + t.Errorf("Details = nil, want %v", tt.expectedDetail) + } else if *result.Details != tt.expectedDetail { + t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail) + } + }) + } +} + +func TestCalculateXAlignedFromScore(t *testing.T) { + tests := []struct { + name string + result *api.AuthResult + expectedScore int + }{ + { + name: "pass result gives positive score", + result: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + expectedScore: 100, + }, + { + name: "fail result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultFail, + }, + expectedScore: 0, + }, + { + name: "neutral result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultNeutral, + }, + expectedScore: 0, + }, + { + name: "none result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultNone, + }, + expectedScore: 0, + }, + { + name: "nil result gives zero score", + result: nil, + expectedScore: 0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{ + XAlignedFrom: tt.result, + } + + score := analyzer.calculateXAlignedFromScore(results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go new file mode 100644 index 0000000..4bba469 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// 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{} + + // 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) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case api.AuthResultResultPass: + // pass: don't alter the score + default: // fail + return -100 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go new file mode 100644 index 0000000..be29a08 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -0,0 +1,83 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.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, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: api.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +} diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go new file mode 100644 index 0000000..d14d157 --- /dev/null +++ b/pkg/analyzer/content.go @@ -0,0 +1,986 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" + "unicode" + + "git.happydns.org/happyDeliver/internal/api" + "golang.org/x/net/html" +) + +// ContentAnalyzer analyzes email content (HTML, links, images) +type ContentAnalyzer struct { + Timeout time.Duration + httpClient *http.Client + listUnsubscribeURLs []string // URLs from List-Unsubscribe header + hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click +} + +// NewContentAnalyzer creates a new content analyzer with configurable timeout +func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { + if timeout == 0 { + timeout = 10 * time.Second // Default timeout + } + return &ContentAnalyzer{ + Timeout: timeout, + httpClient: &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Allow up to 10 redirects + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + }, + } +} + +// ContentResults represents content analysis results +type ContentResults struct { + IsMultipart bool + HTMLValid bool + HTMLErrors []string + Links []LinkCheck + Images []ImageCheck + HasUnsubscribe bool + UnsubscribeLinks []string + TextContent string + HTMLContent string + TextPlainRatio float32 // Ratio of plain text to HTML consistency + ImageTextRatio float32 // Ratio of images to text + SuspiciousURLs []string + ContentIssues []string + HarmfullIssues []string +} + +// HasPlaintext returns true if the email has plain text content +func (r *ContentResults) HasPlaintext() bool { + return r.TextContent != "" +} + +// LinkCheck represents a link validation result +type LinkCheck struct { + URL string + Valid bool + Status int + Error string + IsSafe bool + Warning string +} + +// ImageCheck represents an image validation result +type ImageCheck struct { + Src string + HasAlt bool + AltText string + Valid bool + Error string + IsBroken bool +} + +// AnalyzeContent performs content analysis on email message +func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { + results := &ContentResults{} + + results.IsMultipart = len(email.Parts) > 1 + + // Parse List-Unsubscribe header URLs for use in link detection + c.listUnsubscribeURLs = email.GetListUnsubscribeURLs() + + // Check for one-click unsubscribe support + listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post") + c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click") + + // Get HTML and text parts + htmlParts := email.GetHTMLParts() + textParts := email.GetTextParts() + + // Analyze HTML parts + if len(htmlParts) > 0 { + for _, part := range htmlParts { + c.analyzeHTML(part.Content, results) + } + } + + // Analyze text parts + if len(textParts) > 0 { + for _, part := range textParts { + results.TextContent += part.Content + } + // Extract and validate links from plain text + c.analyzeTextLinks(results.TextContent, results) + } + + // Check plain text/HTML consistency + if len(htmlParts) > 0 && len(textParts) > 0 { + results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) + } else if !results.IsMultipart { + results.TextPlainRatio = 1.0 + } + + return results +} + +// analyzeTextLinks extracts and validates URLs from plain text +func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) { + // Regular expression to match URLs in plain text + // Matches http://, https://, and www. URLs + urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`) + + matches := urlRegex.FindAllString(textContent, -1) + + for _, match := range matches { + // Normalize URL (add http:// if missing) + urlStr := match + if strings.HasPrefix(strings.ToLower(urlStr), "www.") { + urlStr = "http://" + urlStr + } + + // Check if this URL already exists in results.Links (from HTML analysis) + exists := false + for _, link := range results.Links { + if link.URL == urlStr { + exists = true + break + } + } + + // Only validate if not already checked + if !exists { + linkCheck := c.validateLink(urlStr) + results.Links = append(results.Links, linkCheck) + + // Check for suspicious URLs + if !linkCheck.IsSafe { + results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr) + } + } + } +} + +// analyzeHTML parses and analyzes HTML content +func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { + results.HTMLContent = htmlContent + + // Parse HTML + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + results.HTMLValid = false + results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err)) + return + } + + results.HTMLValid = true + + // Traverse HTML tree + c.traverseHTML(doc, results) + + // Calculate image-to-text ratio + if results.HTMLContent != "" { + textLength := len(c.extractTextFromHTML(htmlContent)) + imageCount := len(results.Images) + if textLength > 0 { + results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars + } + } +} + +// traverseHTML recursively traverses HTML nodes +func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { + if n.Type == html.ElementNode { + switch n.Data { + case "a": + // Extract and validate links + href := c.getAttr(n, "href") + if href != "" { + // Check for unsubscribe links + if c.isUnsubscribeLink(href, n) { + results.HasUnsubscribe = true + results.UnsubscribeLinks = append(results.UnsubscribeLinks, href) + } + + // 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 + if !linkCheck.IsSafe { + results.SuspiciousURLs = append(results.SuspiciousURLs, href) + } + } + + case "img": + // Extract and validate images + src := c.getAttr(n, "src") + alt := c.getAttr(n, "alt") + + imageCheck := ImageCheck{ + Src: src, + HasAlt: alt != "", + AltText: alt, + Valid: src != "", + } + + if src == "" { + imageCheck.Error = "Image missing src attribute" + } + + results.Images = append(results.Images, imageCheck) + + case "script": + // JavaScript in emails is a security risk and typically blocked + results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous

More

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

Text

More

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

HTML text

", - ImageTextRatio: 3.0, - }, - minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio - }, - { - name: "With suspicious URLs", - results: &ContentResults{ - HTMLValid: true, - SuspiciousURLs: []string{"url1"}, - }, - minChecks: 3, // HTML, Unsubscribe, Suspicious URLs - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateContentChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Content category - for _, check := range checks { - if check.Category != api.Content { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content) - } - } - }) - } -} - // Helper functions for testing func parseHTML(htmlStr string) (*html.Node, error) { @@ -1076,3 +706,276 @@ func findFirstLink(n *html.Node) *html.Node { func parseURL(urlStr string) (*url.URL, error) { return url.Parse(urlStr) } + +func TestHasDomainMisalignment(t *testing.T) { + tests := []struct { + name string + href string + linkText string + expected bool + reason string + }{ + // Phishing cases - should return true + { + name: "Obvious phishing - different domains", + href: "https://evil.com/page", + linkText: "Click here to verify your paypal.com account", + expected: true, + reason: "Link text shows 'paypal.com' but URL points to 'evil.com'", + }, + { + name: "Domain in link text differs from URL", + href: "http://attacker.net", + linkText: "Visit google.com for more info", + expected: true, + reason: "Link text shows 'google.com' but URL points to 'attacker.net'", + }, + { + name: "URL shown in text differs from actual URL", + href: "https://phishing-site.xyz/login", + linkText: "https://www.bank.example.com/secure", + expected: true, + reason: "Full URL in text doesn't match actual destination", + }, + { + name: "Similar but different domain", + href: "https://paypa1.com/login", + linkText: "Login to your paypal.com account", + expected: true, + reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'", + }, + { + name: "Subdomain spoofing", + href: "https://paypal.com.evil.com/login", + linkText: "Verify your paypal.com account", + expected: true, + reason: "Domain is 'evil.com', not 'paypal.com'", + }, + { + name: "Multiple domains in text, none match", + href: "https://badsite.com", + linkText: "Transfer from bank.com to paypal.com", + expected: true, + reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'", + }, + + // Legitimate cases - should return false + { + name: "Exact domain match", + href: "https://example.com/page", + linkText: "Visit example.com for more information", + expected: false, + reason: "Domains match exactly", + }, + { + name: "Legitimate subdomain", + href: "https://mail.google.com/inbox", + linkText: "Check your google.com email", + expected: false, + reason: "Subdomain of the mentioned domain", + }, + { + name: "www prefix variation", + href: "https://www.example.com/page", + linkText: "Visit example.com", + expected: false, + reason: "www prefix is acceptable variation", + }, + { + name: "Generic link text - click here", + href: "https://anywhere.com", + linkText: "click here", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - read more", + href: "https://example.com/article", + linkText: "Read more", + expected: false, + reason: "Generic text doesn't contain a domain", + }, + { + name: "Generic link text - learn more", + href: "https://example.com/info", + linkText: "Learn More", + expected: false, + reason: "Generic text doesn't contain a domain (case insensitive)", + }, + { + name: "No domain in link text", + href: "https://example.com/page", + linkText: "Click to continue", + expected: false, + reason: "Link text has no domain reference", + }, + { + name: "Short link text", + href: "https://example.com", + linkText: "Go", + expected: false, + reason: "Text too short to contain meaningful domain", + }, + { + name: "Empty link text", + href: "https://example.com", + linkText: "", + expected: false, + reason: "Empty text cannot contain domain", + }, + { + name: "Mailto link - matching domain", + href: "mailto:support@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Mailto email matches text email", + }, + { + name: "Mailto link - domain mismatch (phishing)", + href: "mailto:attacker@evil.com", + linkText: "Contact support@paypal.com for help", + expected: true, + reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'", + }, + { + name: "Mailto link - generic text", + href: "mailto:info@example.com", + linkText: "Contact us", + expected: false, + reason: "Generic text without domain reference", + }, + { + name: "Mailto link - same domain different user", + href: "mailto:sales@example.com", + linkText: "Email support@example.com", + expected: false, + reason: "Both emails share the same domain", + }, + { + name: "Mailto link - text shows only domain", + href: "mailto:info@example.com", + linkText: "Write to example.com", + expected: false, + reason: "Text domain matches mailto domain", + }, + { + name: "Mailto link - domain in text doesn't match", + href: "mailto:scam@phishing.net", + linkText: "Reply to customer-service@amazon.com", + expected: true, + reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text", + }, + { + name: "Tel link", + href: "tel:+1234567890", + linkText: "Call example.com support", + expected: false, + reason: "Non-HTTP(S) links are excluded", + }, + { + name: "Same base domain with different subdomains", + href: "https://www.example.com/page", + linkText: "Visit blog.example.com", + expected: false, + reason: "Both share same base domain 'example.com'", + }, + { + name: "URL with path matches domain in text", + href: "https://example.com/section/page", + linkText: "Go to example.com", + expected: false, + reason: "Domain matches, path doesn't matter", + }, + { + name: "Generic text - subscribe", + href: "https://newsletter.example.com/signup", + linkText: "Subscribe", + expected: false, + reason: "Generic call-to-action text", + }, + { + name: "Generic text - unsubscribe", + href: "https://example.com/unsubscribe?id=123", + linkText: "Unsubscribe", + expected: false, + reason: "Generic unsubscribe text", + }, + { + name: "Generic text - download", + href: "https://files.example.com/document.pdf", + linkText: "Download", + expected: false, + reason: "Generic action text", + }, + { + name: "Descriptive text without domain", + href: "https://shop.example.com/products", + linkText: "View our latest products", + expected: false, + reason: "No domain mentioned in text", + }, + + // Edge cases + { + name: "Domain-like text but not valid domain", + href: "https://example.com", + linkText: "Save up to 50.00 dollars", + expected: false, + reason: "50.00 looks like domain but isn't", + }, + { + name: "Text with http prefix matching domain", + href: "https://example.com/page", + linkText: "Visit http://example.com", + expected: false, + reason: "Domains match despite different protocols in display", + }, + { + name: "Port in URL should not affect matching", + href: "https://example.com:8080/page", + linkText: "Go to example.com", + expected: false, + reason: "Port number doesn't affect domain matching", + }, + { + name: "Whitespace in link text", + href: "https://example.com", + linkText: " example.com ", + expected: false, + reason: "Whitespace should be trimmed", + }, + { + name: "Multiple spaces in generic text", + href: "https://example.com", + linkText: "click here", + expected: false, + reason: "Generic text with extra spaces", + }, + { + name: "Anchor fragment in URL", + href: "https://example.com/page#section", + linkText: "example.com section", + expected: false, + reason: "Fragment doesn't affect domain matching", + }, + { + name: "Query parameters in URL", + href: "https://example.com/page?utm_source=email", + linkText: "Visit example.com", + expected: false, + reason: "Query params don't affect domain matching", + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.hasDomainMisalignment(tt.href, tt.linkText) + if result != tt.expected { + t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s", + tt.href, tt.linkText, result, tt.expected, tt.reason) + } + }) + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go new file mode 100644 index 0000000..3098934 --- /dev/null +++ b/pkg/analyzer/dns.go @@ -0,0 +1,241 @@ +// 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 ( + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// DNSAnalyzer analyzes DNS records for email domains +type DNSAnalyzer struct { + Timeout time.Duration + 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: resolver, + } +} + +// AnalyzeDNS performs DNS validation for the email's domain +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { + // Extract domain from From address + if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { + return &api.DNSResults{ + Errors: &[]string{"Unable to extract domain from email"}, + } + } + fromDomain := *headersResults.DomainAlignment.FromDomain + + results := &api.DNSResults{ + FromDomain: fromDomain, + RpDomain: headersResults.DomainAlignment.ReturnPathDomain, + } + + // Determine which domain to check SPF for (Return-Path domain) + // SPF validates the envelope sender (Return-Path), not the From header + spfDomain := fromDomain + if results.RpDomain != nil { + spfDomain = *results.RpDomain + } + + // Store sender IP for later use in scoring + var senderIP string + if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 { + firstHop := (*headersResults.ReceivedChain)[0] + if firstHop.Ip != nil && *firstHop.Ip != "" { + senderIP = *firstHop.Ip + ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP) + if len(ptrRecords) > 0 { + results.PtrRecords = &ptrRecords + } + if len(forwardRecords) > 0 { + results.PtrForwardRecords = &forwardRecords + } + } + } + + // Check MX records for From domain (where replies would go) + results.FromMxRecords = d.checkMXRecords(fromDomain) + + // Check MX records for Return-Path domain (where bounces would go) + // Only check if Return-Path domain is different from From domain + if results.RpDomain != nil && *results.RpDomain != fromDomain { + results.RpMxRecords = d.checkMXRecords(*results.RpDomain) + } + + // Check SPF records (for Return-Path domain - this is the envelope sender) + // SPF validates the MAIL FROM command, which corresponds to Return-Path + results.SpfRecords = d.checkSPFRecords(spfDomain) + + // Check DKIM records (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 DMARC record (for From domain - DMARC protects the visible sender) + // DMARC validates alignment between SPF/DKIM and the From domain + results.DmarcRecord = d.checkDMARCRecord(fromDomain) + + // Check BIMI record (for From domain - branding is based on visible sender) + results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") + + return results +} + +// AnalyzeDomainOnly performs DNS validation for a domain without email context +// This is useful for checking domain configuration without sending an actual email +func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults { + results := &api.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 *api.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) { + if results == nil { + return 0, "" + } + + score := 0 + + // PTR and Forward DNS: 20 points + score += 20 * d.calculatePTRScore(results, senderIP) / 100 + + // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) + score += 20 * d.calculateMXScore(results) / 100 + + // SPF Records: 20 points + score += 20 * d.calculateSPFScore(results) / 100 + + // DKIM Records: 20 points + score += 20 * d.calculateDKIMScore(results) / 100 + + // DMARC Record: 20 points + score += 20 * d.calculateDMARCScore(results) / 100 + + // BIMI Record + // BIMI is optional but indicates advanced email branding + if results.BimiRecord != nil && results.BimiRecord.Valid { + if score >= 100 { + return 100, "A+" + } + } + + // Ensure score doesn't exceed maximum + if score > 100 { + score = 100 + } + + // Ensure score is non-negative + if score < 0 { + score = 0 + } + + return score, ScoreToGrade(score) +} diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go new file mode 100644 index 0000000..44240e9 --- /dev/null +++ b/pkg/analyzer/dns_bimi.go @@ -0,0 +1,114 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No BIMI record found"), + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: false, + Error: api.PtrTo("BIMI record appears malformed"), + } + } + + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} diff --git a/pkg/analyzer/dns_bimi_test.go b/pkg/analyzer/dns_bimi_test.go new file mode 100644 index 0000000..cf7df83 --- /dev/null +++ b/pkg/analyzer/dns_bimi_test.go @@ -0,0 +1,128 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go new file mode 100644 index 0000000..7ac858d --- /dev/null +++ b/pkg/analyzer/dns_dkim.go @@ -0,0 +1,116 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { + // DKIM records are at: selector._domainkey.domain + dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) + if err != nil { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No DKIM record found"), + } + } + + // Concatenate all TXT record parts (DKIM can be split) + dkimRecord := strings.Join(txtRecords, "") + + // Basic validation - should contain "v=DKIM1" and "p=" (public key) + if !d.validateDKIM(dkimRecord) { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: api.PtrTo(dkimRecord), + Valid: false, + Error: api.PtrTo("DKIM record appears malformed"), + } + } + + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: &dkimRecord, + Valid: true, + } +} + +// validateDKIM performs basic DKIM record validation +func (d *DNSAnalyzer) validateDKIM(record string) bool { + // Should contain p= tag (public key) + if !strings.Contains(record, "p=") { + return false + } + + // Often contains v=DKIM1 but not required + // If v= is present, it should be DKIM1 + if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) { + // DKIM provides strong email authentication + if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { + hasValidDKIM := false + for _, dkim := range *results.DkimRecords { + if dkim.Valid { + hasValidDKIM = true + break + } + } + if hasValidDKIM { + score += 100 + } else { + // Partial credit if DKIM record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go new file mode 100644 index 0000000..8d94d20 --- /dev/null +++ b/pkg/analyzer/dns_dkim_test.go @@ -0,0 +1,72 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestValidateDKIM(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DKIM with version", + record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Valid DKIM without version", + record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Invalid DKIM - no public key", + record: "v=DKIM1; k=rsa", + expected: false, + }, + { + name: "Invalid DKIM - wrong version", + record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: false, + }, + { + name: "Invalid DKIM - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDKIM(tt.record) + if result != tt.expected { + t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go new file mode 100644 index 0000000..3b73ecc --- /dev/null +++ b/pkg/analyzer/dns_dmarc.go @@ -0,0 +1,256 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { + // DMARC records are at: _dmarc.domain + dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) + if err != nil { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + + // Find DMARC record (starts with "v=DMARC1") + var dmarcRecord string + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + dmarcRecord = txt + break + } + } + + if dmarcRecord == "" { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo("No DMARC record found"), + } + } + + // Extract policy + policy := d.extractDMARCPolicy(dmarcRecord) + + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + + // Basic validation + if !d.validateDMARC(dmarcRecord) { + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: api.PtrTo("DMARC record appears malformed"), + } + } + + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, + } +} + +// extractDMARCPolicy extracts the policy from a DMARC record +func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { + // Look for p=none, p=quarantine, or p=reject + re := regexp.MustCompile(`p=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.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 api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.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 api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.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 { + // 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])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + +// validateDMARC performs basic DMARC record validation +func (d *DNSAnalyzer) validateDMARC(record string) bool { + // Must start with v=DMARC1 + if !strings.HasPrefix(record, "v=DMARC1") { + return false + } + + // Must have a policy tag + if !strings.Contains(record, "p=") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) { + // DMARC ties SPF and DKIM together and provides policy + if results.DmarcRecord != nil { + if results.DmarcRecord.Valid { + score += 50 + // Bonus points for stricter policies + if results.DmarcRecord.Policy != nil { + switch *results.DmarcRecord.Policy { + case "reject": + // Strictest policy - full points already awarded + score += 25 + case "quarantine": + // Good policy - no deduction + case "none": + // Weakest policy - deduct 5 points + score -= 25 + } + } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + score += 5 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + score += 5 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 15 + } else { + // Subdomain policy is weaker + score -= 15 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 15 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } + } else if results.DmarcRecord.Record != nil { + // Partial credit if DMARC record exists but has issues + score += 20 + } + } + + return +} diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go new file mode 100644 index 0000000..0868e48 --- /dev/null +++ b/pkg/analyzer/dns_dmarc_test.go @@ -0,0 +1,343 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestExtractDMARCPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy string + }{ + { + name: "Policy none", + record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + expectedPolicy: "none", + }, + { + name: "Policy quarantine", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPolicy: "quarantine", + }, + { + name: "Policy reject", + record: "v=DMARC1; p=reject; sp=reject", + expectedPolicy: "reject", + }, + { + name: "No policy", + record: "v=DMARC1", + expectedPolicy: "unknown", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPolicy(tt.record) + if result != tt.expectedPolicy { + t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + } + }) + } +} + +func TestValidateDMARC(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DMARC", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Valid DMARC minimal", + record: "v=DMARC1; p=none", + expected: true, + }, + { + name: "Invalid DMARC - no version", + record: "p=quarantine", + expected: false, + }, + { + name: "Invalid DMARC - no policy", + record: "v=DMARC1", + expected: false, + }, + { + name: "Invalid DMARC - wrong version", + record: "v=DMARC2; p=reject", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDMARC(tt.record) + if result != tt.expected { + t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractDMARCSPFAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", + }, + { + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", + }, + { + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCDKIMAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", + }, + { + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: api.PtrTo("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: api.PtrTo("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: api.PtrTo("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: api.PtrTo("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + +func TestExtractDMARCPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: api.PtrTo(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: api.PtrTo(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: api.PtrTo(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: api.PtrTo(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: api.PtrTo(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPercentage(tt.record) + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go new file mode 100644 index 0000000..f90e5dc --- /dev/null +++ b/pkg/analyzer/dns_fcr.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + +// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability +func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) { + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 50 points for having PTR records + score += 50 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 15 + } + + // Additional 50 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go new file mode 100644 index 0000000..68e55b5 --- /dev/null +++ b/pkg/analyzer/dns_mx.go @@ -0,0 +1,115 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkMXRecords looks up MX records for a domain +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + mxRecords, err := d.resolver.LookupMX(ctx, domain) + if err != nil { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + }, + } + } + + if len(mxRecords) == 0 { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo("No MX records found"), + }, + } + } + + var results []api.MXRecord + for _, mx := range mxRecords { + results = append(results, api.MXRecord{ + Host: mx.Host, + Priority: mx.Pref, + Valid: true, + }) + } + + return &results +} + +func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) { + // Having valid MX records is critical for email deliverability + // From domain MX records (half points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 50 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go new file mode 100644 index 0000000..f60484f --- /dev/null +++ b/pkg/analyzer/dns_resolver.go @@ -0,0 +1,80 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" +) + +// DNSResolver defines the interface for DNS resolution operations. +// This interface abstracts DNS lookups to allow for custom implementations, +// such as mock resolvers for testing or caching resolvers for performance. +type DNSResolver interface { + // LookupMX returns the DNS MX records for the given domain. + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + + // LookupTXT returns the DNS TXT records for the given domain. + LookupTXT(ctx context.Context, name string) ([]string, error) + + // LookupAddr performs a reverse lookup for the given IP address, + // returning a list of hostnames mapping to that address. + LookupAddr(ctx context.Context, addr string) ([]string, error) + + // LookupHost looks up the given hostname using the local resolver. + // It returns a slice of that host's addresses (IPv4 and IPv6). + LookupHost(ctx context.Context, host string) ([]string, error) +} + +// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver. +type StandardDNSResolver struct { + resolver *net.Resolver +} + +// NewStandardDNSResolver creates a new StandardDNSResolver with default settings. +func NewStandardDNSResolver() DNSResolver { + return &StandardDNSResolver{ + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// LookupMX implements DNSResolver.LookupMX using net.Resolver. +func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + return r.resolver.LookupMX(ctx, name) +} + +// LookupTXT implements DNSResolver.LookupTXT using net.Resolver. +func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + return r.resolver.LookupTXT(ctx, name) +} + +// LookupAddr implements DNSResolver.LookupAddr using net.Resolver. +func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + return r.resolver.LookupAddr(ctx, addr) +} + +// LookupHost implements DNSResolver.LookupHost using net.Resolver. +func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.resolver.LookupHost(ctx, host) +} diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go new file mode 100644 index 0000000..bfa1640 --- /dev/null +++ b/pkg/analyzer/dns_spf.go @@ -0,0 +1,367 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { + visited := make(map[string]bool) + return d.resolveSPFRecords(domain, visited, 0, true) +} + +// resolveSPFRecords recursively resolves SPF records including include: directives +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord { + const maxDepth = 10 // Prevent infinite recursion + + if depth > maxDepth { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("Maximum SPF include depth exceeded"), + }, + } + } + + // Prevent circular references + if visited[domain] { + return &[]api.SPFRecord{} + } + visited[domain] = true + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, domain) + if err != nil { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + }, + } + } + + // Find SPF record (starts with "v=spf1") + var spfRecord string + spfCount := 0 + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=spf1") { + spfRecord = txt + spfCount++ + } + } + + if spfCount == 0 { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("No SPF record found"), + }, + } + } + + var results []api.SPFRecord + + if spfCount > 1 { + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: false, + Error: api.PtrTo("Multiple SPF records found (RFC violation)"), + }) + return &results + } + + // Basic validation + validationErr := d.validateSPF(spfRecord, isMainRecord) + + // Extract the "all" mechanism qualifier + var allQualifier *api.SPFRecordAllQualifier + var errMsg *string + + if validationErr != nil { + errMsg = api.PtrTo(validationErr.Error()) + } else { + // Extract qualifier from the "all" mechanism + if strings.HasSuffix(spfRecord, " -all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) + } else if strings.HasSuffix(spfRecord, " ~all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) + } else if strings.HasSuffix(spfRecord, " +all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } else if strings.HasSuffix(spfRecord, " ?all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) + } else if strings.HasSuffix(spfRecord, " all") { + // Implicit + qualifier (default) + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } + } + + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: validationErr == nil, + AllQualifier: allQualifier, + Error: errMsg, + }) + + // Check for redirect= modifier first (it replaces the entire SPF policy) + redirectDomain := d.extractSPFRedirect(spfRecord) + if redirectDomain != "" { + // redirect= replaces the current domain's policy entirely + // Only follow if no other mechanisms matched (per RFC 7208) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false) + if redirectRecords != nil { + results = append(results, *redirectRecords...) + } + return &results + } + + // Extract and resolve include: directives + includes := d.extractSPFIncludes(spfRecord) + for _, includeDomain := range includes { + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false) + if includedRecords != nil { + results = append(results, *includedRecords...) + } + } + + return &results +} + +// extractSPFIncludes extracts all include: domains from an SPF record +func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { + var includes []string + re := regexp.MustCompile(`include:([^\s]+)`) + matches := re.FindAllStringSubmatch(record, -1) + for _, match := range matches { + if len(match) > 1 { + includes = append(includes, match[1]) + } + } + return includes +} + +// extractSPFRedirect extracts the redirect= domain from an SPF record +// The redirect= modifier replaces the current domain's SPF policy with that of the target domain +func (d *DNSAnalyzer) extractSPFRedirect(record string) string { + re := regexp.MustCompile(`redirect=([^\s]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// isValidSPFMechanism checks if a token is a valid SPF mechanism or modifier +func (d *DNSAnalyzer) isValidSPFMechanism(token string) error { + // Remove qualifier prefix if present (+, -, ~, ?) + mechanism := strings.TrimLeft(token, "+-~?") + + // Check if it's a modifier (contains =) + if strings.Contains(mechanism, "=") { + // Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=) + if strings.HasPrefix(mechanism, "redirect=") || + strings.HasPrefix(mechanism, "exp=") || + strings.HasPrefix(mechanism, "ra=") || + strings.HasPrefix(mechanism, "rp=") || + strings.HasPrefix(mechanism, "rr=") { + return nil + } + + // Check if it's a common mistake (using = instead of :) + parts := strings.SplitN(mechanism, "=", 2) + if len(parts) == 2 { + mechanismName := parts[0] + knownMechanisms := []string{"include", "a", "mx", "ptr", "exists"} + for _, known := range knownMechanisms { + if mechanismName == known { + return fmt.Errorf("invalid syntax '%s': mechanism '%s' should use ':' not '='", token, mechanismName) + } + } + } + + return fmt.Errorf("unknown modifier '%s'", token) + } + + // Check standalone mechanisms (no domain/value required) + if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" { + return nil + } + + // Check mechanisms with domain/value + knownPrefixes := []string{ + "include:", + "a:", "a/", + "mx:", "mx/", + "ptr:", + "ip4:", + "ip6:", + "exists:", + } + + for _, prefix := range knownPrefixes { + if strings.HasPrefix(mechanism, prefix) { + return nil + } + } + + return fmt.Errorf("unknown mechanism '%s'", token) +} + +// validateSPF performs basic SPF record validation +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error { + // Must start with v=spf1 + if !strings.HasPrefix(record, "v=spf1") { + return fmt.Errorf("SPF record must start with 'v=spf1'") + } + + // Parse and validate each token in the SPF record + tokens := strings.Fields(record) + hasRedirect := false + + for i, token := range tokens { + // Skip the version tag + if i == 0 && token == "v=spf1" { + continue + } + + // Check if it's a valid mechanism + if err := d.isValidSPFMechanism(token); err != nil { + return err + } + + // Track if we have a redirect modifier + mechanism := strings.TrimLeft(token, "+-~?") + if strings.HasPrefix(mechanism, "redirect=") { + hasRedirect = true + } + } + + // Check for redirect= modifier (which replaces the need for an 'all' mechanism) + if hasRedirect { + return nil + } + + // Only check for 'all' mechanism on the main record, not on included records + if isMainRecord { + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } + } + + if !hasValidEnding { + return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier") + } + } + + return nil +} + +// hasSPFStrictFail checks if SPF record has strict -all mechanism +func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { + return strings.HasSuffix(record, " -all") +} + +func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { + // SPF is essential for email authentication + if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { + // Find the main SPF record by skipping redirects + // Loop through records to find the last redirect or the first non-redirect + mainSPFIndex := 0 + for i := 0; i < len(*results.SpfRecords); i++ { + spfRecord := (*results.SpfRecords)[i] + if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { + // This is a redirect, check if there's a next record + if i+1 < len(*results.SpfRecords) { + mainSPFIndex = i + 1 + } else { + // Redirect exists but no target record found + break + } + } else { + // Found a non-redirect record + mainSPFIndex = i + break + } + } + + mainSPF := (*results.SpfRecords)[mainSPFIndex] + if mainSPF.Valid { + // Full points for valid SPF + score += 75 + + // Check if DMARC is configured with strict policy as all mechanism is less significant + dmarcStrict := results.DmarcRecord != nil && + results.DmarcRecord.Valid && results.DmarcRecord.Policy != nil && + (*results.DmarcRecord.Policy == "quarantine" || + *results.DmarcRecord.Policy == "reject") + + // Deduct points based on the all mechanism qualifier + if mainSPF.AllQualifier != nil { + switch *mainSPF.AllQualifier { + case "-": + // Strict fail - no deduction, this is the recommended policy + score += 25 + case "~": + // Softfail - if DMARC is quarantine or reject, treat it mostly like strict fail + if dmarcStrict { + score += 20 + } + // Otherwise, moderate penalty (no points added or deducted) + case "+", "?": + // Pass/neutral - severe penalty + if !dmarcStrict { + score -= 25 + } + } + } else { + // No 'all' mechanism qualifier extracted - severe penalty + score -= 25 + } + } else if mainSPF.Record != nil { + // Partial credit if SPF record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go new file mode 100644 index 0000000..2e794ce --- /dev/null +++ b/pkg/analyzer/dns_spf_test.go @@ -0,0 +1,284 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "strings" + "testing" + "time" +) + +func TestValidateSPF(t *testing.T) { + tests := []struct { + name string + record string + expectError bool + errorMsg string // Expected error message (substring match) + }{ + { + name: "Valid SPF with -all", + record: "v=spf1 include:_spf.example.com -all", + expectError: false, + }, + { + name: "Valid SPF with ~all", + record: "v=spf1 ip4:192.0.2.0/24 ~all", + expectError: false, + }, + { + name: "Valid SPF with +all", + record: "v=spf1 +all", + expectError: false, + }, + { + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expectError: false, + }, + { + name: "Valid SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectError: false, + }, + { + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expectError: false, + }, + { + name: "Valid SPF with multiple mechanisms", + record: "v=spf1 a mx ip4:192.0.2.0/24 include:_spf.example.com -all", + expectError: false, + }, + { + name: "Valid SPF with exp modifier", + record: "v=spf1 mx exp=explain.example.com -all", + expectError: false, + }, + { + name: "Invalid SPF - no version", + record: "include:_spf.example.com -all", + expectError: true, + errorMsg: "must start with 'v=spf1'", + }, + { + name: "Invalid SPF - no all mechanism or redirect", + record: "v=spf1 include:_spf.example.com", + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Invalid SPF - wrong version", + record: "v=spf2 include:_spf.example.com -all", + expectError: true, + errorMsg: "must start with 'v=spf1'", + }, + { + name: "Invalid SPF - include= instead of include:", + record: "v=spf1 include=icloud.com ~all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - a= instead of a:", + record: "v=spf1 a=example.com -all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - mx= instead of mx:", + record: "v=spf1 mx=example.com -all", + expectError: true, + errorMsg: "should use ':' not '='", + }, + { + name: "Invalid SPF - unknown mechanism", + record: "v=spf1 foobar -all", + expectError: true, + errorMsg: "unknown mechanism", + }, + { + name: "Invalid SPF - unknown modifier", + record: "v=spf1 -all unknown=value", + expectError: true, + errorMsg: "unknown modifier", + }, + { + name: "Valid SPF with RFC 6652 ra modifier", + record: "v=spf1 mx ra=postmaster -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rp modifier", + record: "v=spf1 mx rp=100 -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rr modifier", + record: "v=spf1 mx rr=all -all", + expectError: false, + }, + { + name: "Valid SPF with all RFC 6652 modifiers", + record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 modifiers and redirect", + record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com", + expectError: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test as main record (isMainRecord = true) since these tests check overall SPF validity + err := analyzer.validateSPF(tt.record, true) + if tt.expectError { + if err == nil { + t.Errorf("validateSPF(%q) expected error but got nil", tt.record) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateSPF(%q) error = %q, want error containing %q", tt.record, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateSPF(%q) unexpected error: %v", tt.record, err) + } + } + }) + } +} + +func TestValidateSPF_IncludedRecords(t *testing.T) { + tests := []struct { + name string + record string + isMainRecord bool + expectError bool + errorMsg string + }{ + { + name: "Main record without 'all' - should error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record without 'all' - should NOT error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: false, + expectError: false, + }, + { + name: "Included record with only mechanisms - should NOT error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with only mechanisms - should error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: true, + expectError: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := analyzer.validateSPF(tt.record, tt.isMainRecord) + if tt.expectError { + if err == nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err) + } + } + }) + } +} + +func TestExtractSPFRedirect(t *testing.T) { + tests := []struct { + name string + record string + expectedRedirect string + }{ + { + name: "SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectedRedirect: "_spf.example.com", + }, + { + name: "SPF with redirect and other mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", + expectedRedirect: "_spf.google.com", + }, + { + name: "SPF without redirect", + record: "v=spf1 include:_spf.example.com -all", + expectedRedirect: "", + }, + { + name: "SPF with only all mechanism", + record: "v=spf1 -all", + expectedRedirect: "", + }, + { + name: "Empty record", + record: "", + expectedRedirect: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractSPFRedirect(tt.record) + if result != tt.expectedRedirect { + t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) + } + }) + } +} diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go new file mode 100644 index 0000000..bba4503 --- /dev/null +++ b/pkg/analyzer/dns_test.go @@ -0,0 +1,58 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestNewDNSAnalyzer(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + expectedTimeout time.Duration + }{ + { + name: "Default timeout", + timeout: 0, + expectedTimeout: 10 * time.Second, + }, + { + name: "Custom timeout", + timeout: 5 * time.Second, + expectedTimeout: 5 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + analyzer := NewDNSAnalyzer(tt.timeout) + if analyzer.Timeout != tt.expectedTimeout { + t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout) + } + if analyzer.resolver == nil { + t.Error("Resolver should not be nil") + } + }) + } +} diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go new file mode 100644 index 0000000..37718bb --- /dev/null +++ b/pkg/analyzer/headers.go @@ -0,0 +1,696 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "net" + "net/mail" + "regexp" + "strings" + "time" + + "golang.org/x/net/publicsuffix" + + "git.happydns.org/happyDeliver/internal/api" +) + +// HeaderAnalyzer analyzes email header quality and structure +type HeaderAnalyzer struct{} + +// NewHeaderAnalyzer creates a new header analyzer +func NewHeaderAnalyzer() *HeaderAnalyzer { + return &HeaderAnalyzer{} +} + +// CalculateHeaderScore evaluates email structural quality from header analysis +func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) { + if analysis == nil || analysis.Headers == nil { + return 0, ' ' + } + + score := 0 + maxGrade := 6 + headers := *analysis.Headers + + // RP and From alignment (25 points) + if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned { + // Bad domain alignment, cap grade to C + maxGrade -= 2 + } else if *analysis.DomainAlignment.Aligned { + score += 25 + } else if *analysis.DomainAlignment.RelaxedAligned { + score += 20 + } + + // Check required headers (RFC 5322) - 30 points + requiredHeaders := []string{"from", "date", "message-id"} + requiredCount := len(requiredHeaders) + presentRequired := 0 + + for _, headerName := range requiredHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRequired++ + } + } + + if presentRequired == requiredCount { + score += 30 + } else { + score += int(30 * (float32(presentRequired) / float32(requiredCount))) + maxGrade = 1 + } + + // Check recommended headers (15 points) + recommendedHeaders := []string{"subject", "to"} + + // Add reply-to when from is a no-reply address + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } + + recommendedCount := len(recommendedHeaders) + presentRecommended := 0 + + for _, headerName := range recommendedHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRecommended++ + } + } + score += presentRecommended * 15 / recommendedCount + + if presentRecommended < recommendedCount { + maxGrade -= 1 + } + + // Check for proper MIME structure (20 points) + if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { + score += 20 + } else { + maxGrade -= 1 + } + + // Check MIME-Version header (-5 points if present but not "1.0") + if check, exists := headers["mime-version"]; exists && check.Present { + if check.Valid != nil && !*check.Valid { + score -= 5 + } + } + + // Check Message-ID format (10 points) + if check, exists := headers["message-id"]; exists && check.Present { + // If Valid is set and true, award points + if check.Valid != nil && *check.Valid { + score += 10 + } else { + maxGrade -= 1 + } + } else { + maxGrade -= 1 + } + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + grade := 'A' + max(6-maxGrade, 0) + + return score, rune(grade) +} + +// isValidMessageID checks if a Message-ID has proper format +func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { + // Basic check: should be in format <...@...> + if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { + return false + } + + // Remove angle brackets + messageID = strings.TrimPrefix(messageID, "<") + messageID = strings.TrimSuffix(messageID, ">") + + // Should contain @ symbol + if !strings.Contains(messageID, "@") { + return false + } + + parts := strings.Split(messageID, "@") + if len(parts) != 2 { + return false + } + + // Both parts should be non-empty + return len(parts[0]) > 0 && len(parts[1]) > 0 +} + +// parseEmailDate attempts to parse an email date string using common email date formats +// Returns the parsed time and an error if parsing fails +func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) { + // Remove timezone name in parentheses if present + dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "") + + // Try parsing with common email date formats + formats := []string{ + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04:05 -0700", + } + + for _, format := range formats { + if parsedTime, err := time.Parse(format, dateStr); err == nil { + return parsedTime, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr) +} + +// isNoReplyAddress checks if a header check represents a no-reply email address +func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool { + if !headerCheck.Present || headerCheck.Value == nil { + return false + } + + value := strings.ToLower(*headerCheck.Value) + noReplyPatterns := []string{ + "no-reply", + "noreply", + "ne-pas-repondre", + "nepasrepondre", + } + + for _, pattern := range noReplyPatterns { + if strings.Contains(value, pattern) { + return true + } + } + + return false +} + +// validateAddressHeader validates email address header using net/mail parser +// and returns the normalized address string in "Name " format +func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) { + // Try to parse as a single address first + if addr, err := mail.ParseAddress(value); err == nil { + return h.formatAddress(addr), nil + } + + // If single address parsing fails, try parsing as an address list + // (for headers like To, Cc that can contain multiple addresses) + if addrs, err := mail.ParseAddressList(value); err != nil { + return "", err + } else { + // Join multiple addresses with ", " + result := "" + for i, addr := range addrs { + if i > 0 { + result += ", " + } + result += h.formatAddress(addr) + } + return result, nil + } +} + +// formatAddress formats a mail.Address as "Name " or just "email" if no name +func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { + if addr.Name != "" { + return fmt.Sprintf("%s <%s>", addr.Name, addr.Address) + } + return addr.Address +} + +// GenerateHeaderAnalysis creates structured header analysis from email +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis { + if email == nil { + return nil + } + + analysis := &api.HeaderAnalysis{} + + // Check for proper MIME structure + analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0) + + // Initialize headers map + headers := make(map[string]api.HeaderCheck) + + // Check required headers + requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"} + for _, headerName := range requiredHeaders { + check := h.checkHeader(email, headerName, "required") + headers[strings.ToLower(headerName)] = *check + } + + // Check recommended headers + recommendedHeaders := []string{} + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } + for _, headerName := range recommendedHeaders { + check := h.checkHeader(email, headerName, "recommended") + headers[strings.ToLower(headerName)] = *check + } + + // Check MIME-Version header (recommended but absence is not penalized) + mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended") + headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck + + // Check optional headers + optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"} + for _, headerName := range optionalHeaders { + check := h.checkHeader(email, headerName, "newsletter") + headers[strings.ToLower(headerName)] = *check + } + + analysis.Headers = &headers + + // Received chain + receivedChain := h.parseReceivedChain(email) + if len(receivedChain) > 0 { + analysis.ReceivedChain = &receivedChain + } + + // Domain alignment + domainAlignment := h.analyzeDomainAlignment(email, authResults) + if domainAlignment != nil { + analysis.DomainAlignment = domainAlignment + } + + // Header issues + issues := h.findHeaderIssues(email) + if len(issues) > 0 { + analysis.Issues = &issues + } + + return analysis +} + +// checkHeader checks if a header is present and valid +func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck { + value := email.GetHeaderValue(headerName) + present := email.HasHeader(headerName) && value != "" + + importanceEnum := api.HeaderCheckImportance(importance) + check := &api.HeaderCheck{ + Present: present, + Importance: &importanceEnum, + } + + if present { + check.Value = &value + + // Validate specific headers + valid := true + var headerIssues []string + + switch headerName { + case "Message-ID": + if !h.isValidMessageID(value) { + valid = false + headerIssues = append(headerIssues, "Invalid Message-ID format (should be )") + } + if len(email.Header["Message-Id"]) > 1 { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"]))) + } + case "Date": + // Validate date format + if _, err := h.parseEmailDate(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err)) + } + case "MIME-Version": + if value != "1.0" { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value)) + } + case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path": + // Parse address header using net/mail and get normalized address + if normalizedAddr, err := h.validateAddressHeader(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err)) + } else { + // Use the normalized address as the value + check.Value = &normalizedAddr + } + } + + check.Valid = &valid + if len(headerIssues) > 0 { + check.Issues = &headerIssues + } + } else { + valid := false + check.Valid = &valid + if importance == "required" { + issues := []string{"Required header is missing"} + check.Issues = &issues + } + } + + return check +} + +// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures +func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment { + alignment := &api.DomainAlignment{ + Aligned: api.PtrTo(true), + RelaxedAligned: api.PtrTo(true), + } + + // Extract From domain + fromAddr := email.GetHeaderValue("From") + if fromAddr != "" { + domain := h.extractDomain(fromAddr) + if domain != "" { + alignment.FromDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.FromOrgDomain = &orgDomain + } + } + + // Extract Return-Path domain + returnPath := email.GetHeaderValue("Return-Path") + if returnPath != "" { + domain := h.extractDomain(returnPath) + if domain != "" { + alignment.ReturnPathDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.ReturnPathOrgDomain = &orgDomain + } + } + + // Extract DKIM domains from authentication results + var dkimDomains []api.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, api.DKIMDomainInfo{ + Domain: domain, + OrgDomain: orgDomain, + }) + } + } + } + if len(dkimDomains) > 0 { + alignment.DkimDomains = &dkimDomains + } + + // Check alignment (strict and relaxed) + issues := []string{} + + // hasReturnPath and hasDKIM track whether we have these fields to check + hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil + hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0 + + // If neither Return-Path nor DKIM is present, keep default alignment (true) + // Otherwise, at least one must be aligned for overall alignment to be true + strictAligned := !hasReturnPath && !hasDKIM + relaxedAligned := !hasReturnPath && !hasDKIM + + // Check Return-Path alignment + rpStrictAligned := false + rpRelaxedAligned := false + if hasReturnPath { + fromDomain := *alignment.FromDomain + rpDomain := *alignment.ReturnPathDomain + + // Strict alignment: exact match (case-insensitive) + rpStrictAligned = strings.EqualFold(fromDomain, rpDomain) + + // Relaxed alignment: organizational domain match + var fromOrgDomain, rpOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + if alignment.ReturnPathOrgDomain != nil { + rpOrgDomain = *alignment.ReturnPathOrgDomain + } + rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain) + + if !rpStrictAligned { + if rpRelaxedAligned { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain)) + } else { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) + } + } + + strictAligned = rpStrictAligned + relaxedAligned = rpRelaxedAligned + } + + // Check DKIM alignment + dkimStrictAligned := false + dkimRelaxedAligned := false + if hasDKIM { + fromDomain := *alignment.FromDomain + var fromOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + + for _, dkimDomain := range dkimDomains { + // Check strict alignment for this DKIM signature + if strings.EqualFold(fromDomain, dkimDomain.Domain) { + dkimStrictAligned = true + } + + // Check relaxed alignment for this DKIM signature + if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) { + dkimRelaxedAligned = true + } + } + + if !dkimStrictAligned && !dkimRelaxedAligned { + // List all DKIM domains that failed alignment + dkimDomainsList := []string{} + for _, dkimDomain := range dkimDomains { + dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain) + } + issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain)) + } else if !dkimStrictAligned && dkimRelaxedAligned { + // DKIM has relaxed alignment but not strict + issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain)) + } + + // Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned + // For DMARC compliance, at least one of SPF or DKIM must be aligned + if dkimStrictAligned { + strictAligned = true + } + if dkimRelaxedAligned { + relaxedAligned = true + } + } + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + + if len(issues) > 0 { + alignment.Issues = &issues + } + + return alignment +} + +// extractDomain extracts domain from email address +func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { + // Remove angle brackets if present + emailAddr = strings.Trim(emailAddr, "<> ") + + // Find @ symbol + atIndex := strings.LastIndex(emailAddr, "@") + if atIndex == -1 { + return "" + } + + domain := emailAddr[atIndex+1:] + // Remove any trailing > + domain = strings.TrimRight(domain, ">") + + return domain +} + +// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name +// using the Public Suffix List (PSL) to correctly handle multi-level TLDs. +// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk +func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + + // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) + // This correctly handles cases like .co.uk, .com.au, etc. + etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + // Fallback to simple two-label extraction if PSL lookup fails + labels := strings.Split(domain, ".") + if len(labels) <= 2 { + return domain + } + return strings.Join(labels[len(labels)-2:], ".") + } + + return etldPlusOne +} + +// findHeaderIssues identifies issues with headers +func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { + var issues []api.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{ + Header: header, + Severity: api.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)), + }) + } + } + + // Check Message-ID format + messageID := email.GetHeaderValue("Message-ID") + if messageID != "" && !h.isValidMessageID(messageID) { + issues = append(issues, api.HeaderIssue{ + Header: "Message-ID", + Severity: api.HeaderIssueSeverityMedium, + Message: "Message-ID format is invalid", + Advice: api.PtrTo("Use proper Message-ID format: "), + }) + } + + return issues +} + +// parseReceivedChain extracts the chain of Received headers from an email +func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop { + if email == nil || email.Header == nil { + return nil + } + + receivedHeaders := email.Header["Received"] + if len(receivedHeaders) == 0 { + return nil + } + + var chain []api.ReceivedHop + + for _, receivedValue := range receivedHeaders { + hop := h.parseReceivedHeader(receivedValue) + if hop != nil { + chain = append(chain, *hop) + } + } + + return chain +} + +// parseReceivedHeader parses a single Received header value +func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop { + hop := &api.ReceivedHop{} + + // Normalize whitespace - Received headers can span multiple lines + normalized := strings.Join(strings.Fields(receivedValue), " ") + + // Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)") + // vs standard "from-first" header (e.g., "from hostname ... by hostname") + isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized)) + + // Extract "from" field - only if not in "by-first" format + // Avoid matching "from" inside parentheses after "by" + if !isByFirst { + fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`) + if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 { + from := matches[1] + hop.From = &from + } + } + + // Extract "by" field + byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`) + if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 { + by := matches[1] + hop.By = &by + } + + // Extract "with" field (protocol) - must come after "by" and before "id" or "for" + // This ensures we get the mail transfer protocol, not other "with" occurrences + // Avoid matching "with" inside parentheses (like in TLS details) + withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`) + if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 { + with := matches[1] + hop.With = &with + } + + // Extract "id" field - should come after "with" or "by", not inside parentheses + // Match pattern: "id " where value doesn't contain parentheses or semicolons + idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`) + if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 { + id := matches[1] + hop.Id = &id + } + + // Extract IP address from parentheses after "from" + // Pattern: from hostname (anything [IPv4/IPv6]) + ipRegex := regexp.MustCompile(`\[([^\]]+)\]`) + if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 { + ipStr := matches[1] + + // Handle IPv6: prefix (some MTAs include this) + ipStr = strings.TrimPrefix(ipStr, "IPv6:") + + // Check if it's a valid IP (IPv4 or IPv6) + if net.ParseIP(ipStr) != nil { + hop.Ip = &ipStr + + // Perform reverse DNS lookup + if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 { + // Remove trailing dot from PTR record + reverse := strings.TrimSuffix(reverseNames[0], ".") + hop.Reverse = &reverse + } + } + } + + // Extract timestamp - usually at the end after semicolon + // Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)" + timestampRegex := regexp.MustCompile(`;\s*(.+)$`) + if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 { + timestampStr := strings.TrimSpace(matches[1]) + + // Use the dedicated date parsing function + if parsedTime, err := h.parseEmailDate(timestampStr); err == nil { + hop.Timestamp = &parsedTime + } + } + + return hop +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go new file mode 100644 index 0000000..2513e6f --- /dev/null +++ b/pkg/analyzer/headers_test.go @@ -0,0 +1,1079 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestCalculateHeaderScore(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minScore int + maxScore int + }{ + { + name: "Nil email", + email: nil, + minScore: 0, + maxScore: 0, + }, + { + name: "Perfect headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0, + maxScore: 40, + }, + { + name: "Required only, no recommended", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 80, + maxScore: 90, + }, + { + name: "Invalid Message-ID format", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "invalid-message-id", + "Subject": "Test", + "To": "recipient@example.com", + "Reply-To": "reply@example.com", + }), + MessageID: "invalid-message-id", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate header analysis first + analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil) + score, _ := analyzer.CalculateHeaderScore(analysis) + if score < tt.minScore || score > tt.maxScore { + t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + }) + } +} + +func TestCheckHeader(t *testing.T) { + tests := []struct { + name string + headerName string + headerValue string + importance string + expectedPresent bool + expectedValid bool + expectedIssuesLen int + }{ + { + name: "Valid Message-ID", + headerName: "Message-ID", + headerValue: "", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Invalid Message-ID format", + headerName: "Message-ID", + headerValue: "invalid-message-id", + importance: "required", + expectedPresent: true, + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Missing required header", + headerName: "From", + headerValue: "", + importance: "required", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Missing optional header", + headerName: "Reply-To", + headerValue: "", + importance: "optional", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 0, + }, + { + name: "Valid Date header", + headerName: "Date", + headerValue: "Mon, 01 Jan 2024 12:00:00 +0000", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + tt.headerName: tt.headerValue, + }), + } + + check := analyzer.checkHeader(email, tt.headerName, tt.importance) + + if check.Present != tt.expectedPresent { + t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent) + } + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + if check.Importance == nil { + t.Error("Importance is nil") + } else if string(*check.Importance) != tt.importance { + t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen) + } + }) + } +} + +func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) { + tests := []struct { + name string + messageID string + expected bool + }{ + { + name: "Valid Message-ID", + messageID: "", + expected: true, + }, + { + name: "Valid with complex local part", + messageID: "", + expected: true, + }, + { + name: "Missing angle brackets", + messageID: "abc123@example.com", + expected: false, + }, + { + name: "Missing @ symbol", + messageID: "", + expected: false, + }, + { + name: "Empty local part", + messageID: "<@example.com>", + expected: false, + }, + { + name: "Empty domain", + messageID: "", + expected: false, + }, + { + name: "Multiple @ symbols", + messageID: "", + expected: false, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.isValidMessageID(tt.messageID) + if result != tt.expected { + t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) + } + }) + } +} + +func TestHeaderAnalyzer_ExtractDomain(t *testing.T) { + tests := []struct { + name string + email string + expected string + }{ + { + name: "Simple email", + email: "user@example.com", + expected: "example.com", + }, + { + name: "Email with angle brackets", + email: "", + expected: "example.com", + }, + { + name: "Email with display name", + email: "User Name ", + expected: "example.com", + }, + { + name: "Email with spaces", + email: " user@example.com ", + expected: "example.com", + }, + { + name: "Invalid email", + email: "not-an-email", + expected: "", + }, + { + name: "Empty string", + email: "", + expected: "", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDomain(tt.email) + if result != tt.expected { + t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected) + } + }) + } +} + +func TestAnalyzeDomainAlignment(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + expectAligned bool + expectIssuesLen int + }{ + { + name: "Aligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + expectAligned: true, + expectIssuesLen: 0, + }, + { + name: "Misaligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + expectAligned: false, + expectIssuesLen: 1, + }, + { + name: "Only From header", + fromHeader: "sender@example.com", + returnPath: "", + expectAligned: true, + expectIssuesLen: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + alignment := analyzer.analyzeDomainAlignment(email, nil) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned) + } + + issuesLen := 0 + if alignment.Issues != nil { + issuesLen = len(*alignment.Issues) + } + if issuesLen != tt.expectIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen) + } + }) + } +} + +// Helper function to create mail.Header with specific fields +func createHeaderWithFields(fields map[string]string) mail.Header { + header := make(mail.Header) + for key, value := range fields { + if value != "" { + // Use canonical MIME header key format + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + header[canonicalKey] = []string{value} + } + } + return header +} + +func TestParseReceivedChain(t *testing.T) { + tests := []struct { + name string + receivedHeaders []string + expectedHops int + validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop) + }{ + { + name: "No Received headers", + receivedHeaders: []string{}, + expectedHops: 0, + }, + { + name: "Single Received header", + receivedHeaders: []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "mail.example.com" { + t.Errorf("From = %v, want 'mail.example.com'", hop.From) + } + if hop.By == nil || *hop.By != "mx.receiver.com" { + t.Errorf("By = %v, want 'mx.receiver.com'", hop.By) + } + if hop.With == nil || *hop.With != "ESMTPS" { + t.Errorf("With = %v, want 'ESMTPS'", hop.With) + } + if hop.Id == nil || *hop.Id != "ABC123" { + t.Errorf("Id = %v, want 'ABC123'", hop.Id) + } + if hop.Ip == nil || *hop.Ip != "192.0.2.1" { + t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip) + } + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + }, + }, + { + name: "Multiple Received headers", + receivedHeaders: []string{ + "from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000", + "from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000", + }, + expectedHops: 2, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) != 2 { + t.Fatalf("Expected 2 hops, got %d", len(hops)) + } + + // Check first hop + if hops[0].From == nil || *hops[0].From != "mail1.example.com" { + t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From) + } + + // Check second hop + if hops[1].From == nil || *hops[1].From != "mail2.example.com" { + t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From) + } + }, + }, + { + name: "IPv6 address", + receivedHeaders: []string{ + "from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.Ip == nil { + t.Fatal("IP should not be nil for IPv6 address") + } + // Should strip the "IPv6:" prefix + if *hop.Ip != "2607:5300:203:2818::1" { + t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip) + } + }, + }, + { + name: "Multiline Received header", + receivedHeaders: []string{ + `from nemunai.re (unknown [IPv6:2607:5300:203:2818::1]) + (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) + key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) + (No client certificate requested) + (Authenticated sender: nemunaire) + by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA + for ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`, + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "nemunai.re" { + t.Errorf("From = %v, want 'nemunai.re'", hop.From) + } + if hop.By == nil || *hop.By != "djehouty.pomail.fr" { + t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By) + } + if hop.With == nil { + t.Error("With should not be nil") + } else if *hop.With != "ESMTPSA" { + t.Errorf("With = %q, want 'ESMTPSA'", *hop.With) + } + if hop.Id == nil || *hop.Id != "1EFD11611EA" { + t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id) + } + }, + }, + { + name: "Received header with minimal information", + receivedHeaders: []string{ + "from unknown by localhost", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "unknown" { + t.Errorf("From = %v, want 'unknown'", hop.From) + } + if hop.By == nil || *hop.By != "localhost" { + t.Errorf("By = %v, want 'localhost'", hop.By) + } + }, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(mail.Header) + if len(tt.receivedHeaders) > 0 { + header["Received"] = tt.receivedHeaders + } + + email := &EmailMessage{ + Header: header, + } + + chain := analyzer.parseReceivedChain(email) + + if len(chain) != tt.expectedHops { + t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops) + } + + if tt.validateFirst != nil { + tt.validateFirst(t, email, chain) + } + }) + } +} + +func TestParseReceivedHeader(t *testing.T) { + tests := []struct { + name string + receivedValue string + expectFrom *string + expectBy *string + expectWith *string + expectId *string + expectIp *string + expectHasTs bool + }{ + { + name: "Complete Received header", + receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("ESMTPS"), + expectId: strPtr("ABC123"), + expectIp: strPtr("192.0.2.1"), + expectHasTs: true, + }, + { + name: "Minimal Received header", + receivedValue: "from sender.example.com by receiver.example.com", + expectFrom: strPtr("sender.example.com"), + expectBy: strPtr("receiver.example.com"), + expectWith: nil, + expectId: nil, + expectIp: nil, + expectHasTs: false, + }, + { + name: "Received header with ESMTPA", + receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500", + expectFrom: strPtr("[192.0.2.50]"), + expectBy: strPtr("mail.example.com"), + expectWith: strPtr("ESMTPA"), + expectId: strPtr("XYZ789"), + expectIp: strPtr("192.0.2.50"), + expectHasTs: true, + }, + { + name: "Received header without IP", + receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("SMTP"), + expectId: nil, + expectIp: nil, + expectHasTs: true, + }, + { + name: "Postfix local delivery with userid", + receivedValue: "by grunt.ycc.fr (Postfix, from userid 1000) id 67276801A8; Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectFrom: nil, + expectBy: strPtr("grunt.ycc.fr"), + expectWith: nil, + expectId: strPtr("67276801A8"), + expectIp: nil, + expectHasTs: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hop := analyzer.parseReceivedHeader(tt.receivedValue) + + if hop == nil { + t.Fatal("parseReceivedHeader returned nil") + } + + // Check From + if !equalStrPtr(hop.From, tt.expectFrom) { + t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom)) + } + + // Check By + if !equalStrPtr(hop.By, tt.expectBy) { + t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy)) + } + + // Check With + if !equalStrPtr(hop.With, tt.expectWith) { + t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith)) + } + + // Check Id + if !equalStrPtr(hop.Id, tt.expectId) { + t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId)) + } + + // Check Ip + if !equalStrPtr(hop.Ip, tt.expectIp) { + t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp)) + } + + // Check Timestamp + if tt.expectHasTs { + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + } + }) + } +} + +func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { + analyzer := NewHeaderAnalyzer() + + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + } + + // Add Received headers + email.Header["Received"] = []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000", + "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000", + } + + analysis := analyzer.GenerateHeaderAnalysis(email, nil) + + if analysis == nil { + t.Fatal("GenerateHeaderAnalysis returned nil") + } + + if analysis.ReceivedChain == nil { + t.Fatal("ReceivedChain should not be nil") + } + + chain := *analysis.ReceivedChain + if len(chain) != 2 { + t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain)) + } + + // Check first hop + if chain[0].From == nil || *chain[0].From != "mail.example.com" { + t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From) + } + + // Check second hop + if chain[1].From == nil || *chain[1].From != "relay.example.com" { + t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From) + } +} + +func TestHeaderAnalyzer_ParseEmailDate(t *testing.T) { + tests := []struct { + name string + dateStr string + expectError bool + expectYear int + expectMonth int + expectDay int + }{ + { + name: "RFC1123Z format", + dateStr: "Mon, 02 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "RFC1123 format", + dateStr: "Mon, 02 Jan 2006 15:04:05 MST", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Single digit day", + dateStr: "Mon, 2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Without day of week", + dateStr: "2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "With timezone name in parentheses", + dateStr: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectError: false, + expectYear: 2024, + expectMonth: 1, + expectDay: 1, + }, + { + name: "With timezone name in parentheses 2", + dateStr: "Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 19, + }, + { + name: "With CEST timezone", + dateStr: "Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 24, + }, + { + name: "Invalid date format", + dateStr: "not a date", + expectError: true, + }, + { + name: "Empty string", + dateStr: "", + expectError: true, + }, + { + name: "ISO 8601 format (should fail)", + dateStr: "2024-01-01T12:00:00Z", + expectError: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := analyzer.parseEmailDate(tt.dateStr) + + if tt.expectError { + if err == nil { + t.Errorf("parseEmailDate(%q) expected error, got nil", tt.dateStr) + } + } else { + if err != nil { + t.Errorf("parseEmailDate(%q) unexpected error: %v", tt.dateStr, err) + return + } + + if result.Year() != tt.expectYear { + t.Errorf("Year = %d, want %d", result.Year(), tt.expectYear) + } + if int(result.Month()) != tt.expectMonth { + t.Errorf("Month = %d, want %d", result.Month(), tt.expectMonth) + } + if result.Day() != tt.expectDay { + t.Errorf("Day = %d, want %d", result.Day(), tt.expectDay) + } + } + }) + } +} + +func TestCheckHeader_DateValidation(t *testing.T) { + tests := []struct { + name string + dateValue string + expectedValid bool + expectedIssuesLen int + }{ + { + name: "Valid RFC1123Z date", + dateValue: "Mon, 02 Jan 2006 15:04:05 -0700", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Valid date with timezone name", + dateValue: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Invalid date format", + dateValue: "2024-01-01", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Invalid date string", + dateValue: "not a date", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Empty date", + dateValue: "", + expectedValid: false, + expectedIssuesLen: 1, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Date": tt.dateValue, + }), + } + + check := analyzer.checkHeader(email, "Date", "required") + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d (issues: %v)", issuesLen, tt.expectedIssuesLen, check.Issues) + } + }) + } +} + +// Helper functions for testing +func strPtr(s string) *string { + return &s +} + +func ptrToStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func equalStrPtr(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + dkimDomains []string + expectStrictAligned bool + expectRelaxedAligned bool + expectIssuesContain string + }{ + { + name: "DKIM strict alignment with From domain", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "DKIM relaxed alignment only", + fromHeader: "sender@mail.example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: false, + expectRelaxedAligned: true, + expectIssuesContain: "relaxed alignment", + }, + { + name: "DKIM no alignment", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not align", + }, + { + name: "Multiple DKIM signatures - one aligns", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com", "example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Return-Path misaligned but DKIM aligned", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "Return-Path", + }, + { + name: "Return-Path aligned, no DKIM", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + dkimDomains: []string{}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Both Return-Path and DKIM misaligned", + fromHeader: "sender@example.com", + returnPath: "bounce@other.com", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + // Create authentication results with DKIM signatures + var authResults *api.AuthenticationResults + if len(tt.dkimDomains) > 0 { + dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains)) + for _, domain := range tt.dkimDomains { + dkimResults = append(dkimResults, api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: &domain, + }) + } + authResults = &api.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/internal/analyzer/parser.go b/pkg/analyzer/parser.go similarity index 80% rename from internal/analyzer/parser.go rename to pkg/analyzer/parser.go index 13c012c..5b30e07 100644 --- a/internal/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,9 +28,16 @@ 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 @@ -211,8 +218,27 @@ 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 { - return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] + allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] + + // If no hostname specified, return all results + if hostname == "" { + return allResults + } + + // Filter results that begin with the specified hostname + var filtered []string + prefix := hostname + ";" + for _, result := range allResults { + // Trim whitespace and check if it starts with hostname; + trimmed := strings.TrimSpace(result) + if strings.HasPrefix(trimmed, prefix) { + filtered = append(filtered, result) + } + } + + return filtered } // GetSpamAssassinHeaders extracts SpamAssassin-related headers @@ -230,6 +256,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string { } for _, headerName := range saHeaders { + if values, ok := e.Header[headerName]; ok && len(values) > 0 { + for _, value := range values { + if strings.TrimSpace(value) != "" { + headers[headerName] = value + break + } + } + } else if value := e.Header.Get(headerName); value != "" { + headers[headerName] = value + } + } + + return headers +} + +// GetRspamdHeaders extracts rspamd-related headers +func (e *EmailMessage) GetRspamdHeaders() map[string]string { + headers := make(map[string]string) + + rspamdHeaders := []string{ + "X-Spamd-Result", + "X-Rspamd-Score", + "X-Rspamd-Action", + "X-Rspamd-Server", + } + + for _, headerName := range rspamdHeaders { if value := e.Header.Get(headerName); value != "" { headers[headerName] = value } @@ -275,3 +328,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/internal/analyzer/parser_test.go b/pkg/analyzer/parser_test.go similarity index 99% rename from internal/analyzer/parser_test.go rename to pkg/analyzer/parser_test.go index 571f542..eb1fc6a 100644 --- a/internal/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8 } func TestGetAuthenticationResults(t *testing.T) { + // Force hostname + hostname = "example.com" + rawEmail := `From: sender@example.com To: recipient@example.com Subject: Test Email diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go new file mode 100644 index 0000000..08d3b8f --- /dev/null +++ b/pkg/analyzer/rbl.go @@ -0,0 +1,346 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net" + "regexp" + "strings" + "sync" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// DNSListChecker checks IP addresses against DNS-based block/allow lists. +// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags. +type DNSListChecker struct { + Timeout time.Duration + Lists []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors + resolver *net.Resolver + informationalSet map[string]bool // Lists whose hits don't count toward the score +} + +// DefaultRBLs is a list of commonly used RBL providers +var DefaultRBLs = []string{ + "zen.spamhaus.org", // Spamhaus combined list + "bl.spamcop.net", // SpamCop + "dnsbl.sorbs.net", // SORBS + "b.barracudacentral.org", // Barracuda + "cbl.abuseat.org", // CBL (Composite Blocking List) + "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational) + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational) + "psbl.surriel.com", // PSBL + "dnsbl.dronebl.org", // DroneBL + "bl.mailspike.net", // Mailspike BL + "z.mailspike.net", // Mailspike Z + "bl.rbl-dns.com", // RBL-DNS + "bl.nszones.com", // NSZones +} + +// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score. +// These are typically broader lists where being listed is less definitive. +var DefaultInformationalRBLs = []string{ + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring +} + +// DefaultDNSWLs is a list of commonly used DNSWL providers +var DefaultDNSWLs = []string{ + "list.dnswl.org", // DNSWL.org — the main DNS whitelist + "swl.spamhaus.org", // Spamhaus Safe Whitelist +} + +// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list +func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker { + if timeout == 0 { + timeout = 5 * time.Second + } + if len(rbls) == 0 { + rbls = DefaultRBLs + } + informationalSet := make(map[string]bool, len(DefaultInformationalRBLs)) + for _, rbl := range DefaultInformationalRBLs { + informationalSet[rbl] = true + } + return &DNSListChecker{ + Timeout: timeout, + Lists: rbls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: true, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: informationalSet, + } +} + +// 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), + } +} + +// DNSListResults represents the results of DNS list checks +type DNSListResults struct { + Checks map[string][]api.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][]api.BlacklistCheck), + } + + ips := r.extractIPs(email) + if len(ips) == 0 { + return results + } + + results.IPsChecked = ips + + for _, ip := range ips { + 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) ([]api.BlacklistCheck, int, error) { + if !r.isPublicIP(ip) { + return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) + } + + checks := make([]api.BlacklistCheck, len(r.Lists)) + var wg sync.WaitGroup + + for i, list := range r.Lists { + wg.Add(1) + go func(i int, list string) { + defer wg.Done() + checks[i] = r.checkIP(ip, list) + }(i, list) + } + wg.Wait() + + listedCount := 0 + for _, check := range checks { + if check.Listed { + listedCount++ + } + } + + return checks, listedCount, nil +} + +// extractIPs extracts IP addresses from Received headers +func (r *DNSListChecker) extractIPs(email *EmailMessage) []string { + var ips []string + seenIPs := make(map[string]bool) + + receivedHeaders := email.Header["Received"] + 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`) + + for _, received := range receivedHeaders { + matches := ipv4Pattern.FindAllString(received, -1) + for _, match := range matches { + if !r.isPublicIP(match) { + continue + } + if !seenIPs[match] { + ips = append(ips, match) + seenIPs[match] = true + } + } + } + + if len(ips) == 0 { + originatingIP := email.Header.Get("X-Originating-IP") + if originatingIP != "" { + cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") + cleanIP = strings.TrimSpace(cleanIP) + matches := ipv4Pattern.FindString(cleanIP) + if matches != "" && r.isPublicIP(matches) { + ips = append(ips, matches) + } + } + } + + return ips +} + +// isPublicIP checks if an IP address is public (not private, loopback, or reserved) +func (r *DNSListChecker) isPublicIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + + if ip.IsUnspecified() { + return false + } + + return true +} + +// checkIP checks a single IP against a single DNS list +func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck { + check := api.BlacklistCheck{ + Rbl: list, + } + + reversedIP := r.reverseIP(ip) + if reversedIP == "" { + check.Error = api.PtrTo("Failed to reverse IP address") + return check + } + + query := fmt.Sprintf("%s.%s", reversedIP, list) + + ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) + defer cancel() + + addrs, err := r.resolver.LookupHost(ctx, query) + if err != nil { + if dnsErr, ok := err.(*net.DNSError); ok { + if dnsErr.IsNotFound { + check.Listed = false + return check + } + } + check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) + return check + } + + if len(addrs) > 0 { + check.Response = api.PtrTo(addrs[0]) + + // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings. + if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") { + check.Listed = false + check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0])) + } else { + check.Listed = true + } + } + + return check +} + +// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries +// Example: 192.0.2.1 -> 1.2.0.192 +func (r *DNSListChecker) reverseIP(ipStr string) string { + ip := net.ParseIP(ipStr) + if ip == nil { + return "" + } + + ipv4 := ip.To4() + if ipv4 == nil { + return "" // IPv6 not supported yet + } + + return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) +} + +// CalculateScore calculates the list contribution to deliverability. +// Informational lists are not counted in the score. +func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { + if results == nil || len(results.IPsChecked) == 0 { + return 100, "" + } + + scoringListCount := len(r.Lists) - len(r.informationalSet) + if scoringListCount <= 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 entry +func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string { + var listedIPs []string + + for ip, checks := range results.Checks { + for _, check := range checks { + if check.Listed { + listedIPs = append(listedIPs, ip) + break + } + } + } + + return listedIPs +} + +// GetListsForIP returns all lists that match a specific IP +func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string { + var lists []string + + if checks, exists := results.Checks[ip]; exists { + for _, check := range checks { + if check.Listed { + lists = append(lists, check.Rbl) + } + } + } + + return lists +} diff --git a/internal/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go similarity index 57% rename from internal/analyzer/rbl_test.go rename to pkg/analyzer/rbl_test.go index a75ef19..1dd1262 100644 --- a/internal/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -23,7 +23,6 @@ package analyzer import ( "net/mail" - "strings" "testing" "time" @@ -56,12 +55,12 @@ func TestNewRBLChecker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := NewRBLChecker(tt.timeout, tt.rbls) + checker := NewRBLChecker(tt.timeout, tt.rbls, false) if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.RBLs) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) + if len(checker.Lists) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -98,7 +97,7 @@ func TestReverseIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -158,7 +157,7 @@ func TestIsPublicIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -238,7 +237,7 @@ func TestExtractIPs(t *testing.T) { },*/ } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -266,68 +265,68 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults - expectedScore float32 + results *DNSListResults + expectedScore int }{ { name: "Nil results", results: nil, - expectedScore: 2.0, + expectedScore: 100, }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Listed on 1 RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 1.0, + expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) }, { name: "Listed on 2 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 0.5, + expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) }, { name: "Listed on 3 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 0.5, + expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, - expectedScore: 0.0, + expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := checker.GetBlacklistScore(tt.results) + score, _ := checker.CalculateScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -335,215 +334,24 @@ func TestGetBlacklistScore(t *testing.T) { } } -func TestGenerateSummaryCheck(t *testing.T) { - tests := []struct { - name string - results *RBLResults - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "Not listed", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: make([]RBLCheck, 6), // 6 default RBLs - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 2.0, - }, - { - name: "Listed on 1 RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 1.0, - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, - }, - { - name: "Listed on 4+ RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, - Checks: make([]RBLCheck, 6), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateSummaryCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - }) - } -} - -func TestGenerateListingCheck(t *testing.T) { - tests := []struct { - name string - rblCheck *RBLCheck - expectedStatus api.CheckStatus - expectedSeverity api.CheckSeverity - }{ - { - name: "Spamhaus listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "zen.spamhaus.org", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.Critical, - }, - { - name: "SpamCop listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "bl.spamcop.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, - }, - { - name: "Other RBL listing", - rblCheck: &RBLCheck{ - IP: "198.51.100.1", - RBL: "dnsbl.sorbs.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateListingCheck(tt.rblCheck) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Severity == nil || *check.Severity != tt.expectedSeverity { - t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - if !strings.Contains(check.Name, tt.rblCheck.RBL) { - t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL) - } - }) - } -} - -func TestGenerateRBLChecks(t *testing.T) { - tests := []struct { - name string - results *RBLResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "No IPs checked", - results: &RBLResults{ - IPsChecked: []string{}, - }, - minChecks: 1, // Warning check - }, - { - name: "Not listed on any RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false}, - }, - }, - minChecks: 1, // Summary check only - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, - }, - }, - minChecks: 3, // Summary + 2 listing checks - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := checker.GenerateRBLChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Blacklist category - for _, check := range checks { - if check.Category != api.Blacklist { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist) - } - } - }) - } -} - func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false}, - {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false}, + results := &DNSListResults{ + Checks: map[string][]api.BlacklistCheck{ + "198.51.100.1": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, + }, + "198.51.100.2": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: false}, + }, + "198.51.100.3": { + {Rbl: "zen.spamhaus.org", Listed: false}, + }, }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) listedIPs := checker.GetUniqueListedIPs(results) expectedIPs := []string{"198.51.100.1", "198.51.100.2"} @@ -555,16 +363,20 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + results := &DNSListResults{ + Checks: map[string][]api.BlacklistCheck{ + "198.51.100.1": { + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, + {Rbl: "dnsbl.sorbs.net", Listed: false}, + }, + "198.51.100.2": { + {Rbl: "zen.spamhaus.org", Listed: true}, + }, }, } - checker := NewRBLChecker(5*time.Second, nil) + checker := NewRBLChecker(5*time.Second, nil, false) tests := []struct { name string @@ -590,7 +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 new file mode 100644 index 0000000..bd12960 --- /dev/null +++ b/pkg/analyzer/report.go @@ -0,0 +1,306 @@ +// 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 ( + "time" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/utils" + "github.com/google/uuid" +) + +// ReportGenerator generates comprehensive deliverability reports +type ReportGenerator struct { + authAnalyzer *AuthenticationAnalyzer + spamAnalyzer *SpamAssassinAnalyzer + rspamdAnalyzer *RspamdAnalyzer + dnsAnalyzer *DNSAnalyzer + rblChecker *DNSListChecker + dnswlChecker *DNSListChecker + contentAnalyzer *ContentAnalyzer + headerAnalyzer *HeaderAnalyzer +} + +// NewReportGenerator creates a new report generator +func NewReportGenerator( + dnsTimeout time.Duration, + httpTimeout time.Duration, + rbls []string, + dnswls []string, + checkAllIPs bool, +) *ReportGenerator { + return &ReportGenerator{ + authAnalyzer: NewAuthenticationAnalyzer(), + spamAnalyzer: NewSpamAssassinAnalyzer(), + rspamdAnalyzer: NewRspamdAnalyzer(), + dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), + rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), + dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), + contentAnalyzer: NewContentAnalyzer(httpTimeout), + headerAnalyzer: NewHeaderAnalyzer(), + } +} + +// AnalysisResults contains all intermediate analysis results +type AnalysisResults struct { + Email *EmailMessage + Authentication *api.AuthenticationResults + Content *ContentResults + DNS *api.DNSResults + Headers *api.HeaderAnalysis + RBL *DNSListResults + DNSWL *DNSListResults + SpamAssassin *api.SpamAssassinResult + Rspamd *api.RspamdResult +} + +// AnalyzeEmail performs complete email analysis +func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { + results := &AnalysisResults{ + Email: email, + } + + // Run all analyzers + results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, 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 { + reportID := uuid.New() + now := time.Now() + + report := &api.Report{ + Id: utils.UUIDToBase32(reportID), + TestId: utils.UUIDToBase32(testID), + CreatedAt: now, + } + + // Calculate scores directly from analyzers (no more checks array) + dnsScore := 0 + var dnsGrade string + if results.DNS != nil { + // Extract sender IP from received chain for FCrDNS verification + var senderIP string + if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 { + firstHop := (*results.Headers.ReceivedChain)[0] + if firstHop.Ip != nil { + senderIP = *firstHop.Ip + } + } + dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP) + } + + authScore := 0 + var authGrade string + if results.Authentication != nil { + authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) + } + + contentScore := 0 + var contentGrade string + if results.Content != nil { + contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content) + } + + headerScore := 0 + var headerGrade rune + if results.Headers != nil { + headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers) + } + + blacklistScore := 0 + var blacklistGrade string + if results.RBL != nil { + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL) + } + + saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) + rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd) + + // Combine SpamAssassin and rspamd scores 50/50. + // If only one filter ran (the other returns "" grade), use that filter's score alone. + var spamScore int + var spamGrade string + switch { + case saGrade == "" && rspamdGrade == "": + spamScore = 0 + spamGrade = "" + case saGrade == "": + spamScore = rspamdScore + spamGrade = rspamdGrade + case rspamdGrade == "": + spamScore = saScore + spamGrade = saGrade + default: + spamScore = (saScore + rspamdScore) / 2 + spamGrade = MinGrade(saGrade, rspamdGrade) + } + + report.Summary = &api.ScoreSummary{ + DnsScore: dnsScore, + DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade), + AuthenticationScore: authScore, + AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade), + BlacklistScore: blacklistScore, + BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade), + ContentScore: contentScore, + ContentGrade: api.ScoreSummaryContentGrade(contentGrade), + HeaderScore: headerScore, + HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade), + SpamScore: spamScore, + SpamGrade: api.ScoreSummarySpamGrade(spamGrade), + } + + // Add authentication results + report.Authentication = results.Authentication + + // Add content analysis + if results.Content != nil { + contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content) + report.ContentAnalysis = contentAnalysis + } + + // Add DNS records + if results.DNS != nil { + report.DnsResults = results.DNS + } + + // Add headers results + report.HeaderAnalysis = results.Headers + + // Add blacklist checks as a map of IP -> array of BlacklistCheck + if results.RBL != nil && len(results.RBL.Checks) > 0 { + report.Blacklists = &results.RBL.Checks + } + + // Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only) + if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 { + report.Whitelists = &results.DNSWL.Checks + } + + // Add SpamAssassin result with individual deliverability score + if results.SpamAssassin != nil { + saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade) + results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore) + results.SpamAssassin.DeliverabilityGrade = &saGradeTyped + } + report.Spamassassin = results.SpamAssassin + + // Add rspamd result with individual deliverability score + if results.Rspamd != nil { + rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade) + results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore) + results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped + } + report.Rspamd = results.Rspamd + + // Add raw headers + if results.Email != nil && results.Email.RawHeaders != "" { + report.RawHeaders = &results.Email.RawHeaders + } + + // Calculate overall score as mean of all category scores + categoryScores := []int{ + report.Summary.DnsScore, + report.Summary.AuthenticationScore, + report.Summary.BlacklistScore, + report.Summary.ContentScore, + report.Summary.HeaderScore, + report.Summary.SpamScore, + } + + var totalScore int + var categoryCount int + for _, score := range categoryScores { + totalScore += score + categoryCount++ + } + + if categoryCount > 0 { + report.Score = totalScore / categoryCount + } else { + report.Score = 0 + } + + report.Grade = ScoreToReportGrade(report.Score) + categoryGrades := []string{ + string(report.Summary.DnsGrade), + string(report.Summary.AuthenticationGrade), + string(report.Summary.BlacklistGrade), + string(report.Summary.ContentGrade), + string(report.Summary.HeaderGrade), + string(report.Summary.SpamGrade), + } + if report.Score >= 100 { + hasLessThanA := false + + for _, grade := range categoryGrades { + if len(grade) < 1 || grade[0] != 'A' { + hasLessThanA = true + } + } + + if !hasLessThanA { + report.Grade = "A+" + } + } else { + var minusGrade byte = 0 + for _, grade := range categoryGrades { + if len(grade) == 0 { + minusGrade = 255 + break + } else if grade[0]-'A' > minusGrade { + minusGrade = grade[0] - 'A' + } + } + + if minusGrade < 255 { + report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade})) + } + } + + return report +} + +// GenerateRawEmail returns the raw email message as a string +func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { + if email == nil { + return "" + } + + raw := email.RawHeaders + if email.RawBody != "" { + raw += "\n" + email.RawBody + } + + return raw +} diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go new file mode 100644 index 0000000..82e923e --- /dev/null +++ b/pkg/analyzer/report_test.go @@ -0,0 +1,228 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/utils" + "github.com/google/uuid" +) + +func TestNewReportGenerator(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + if gen == nil { + t.Fatal("Expected report generator, got nil") + } + + if gen.authAnalyzer == nil { + t.Error("authAnalyzer should not be nil") + } + if gen.spamAnalyzer == nil { + t.Error("spamAnalyzer should not be nil") + } + if gen.dnsAnalyzer == nil { + t.Error("dnsAnalyzer should not be nil") + } + if gen.rblChecker == nil { + t.Error("rblChecker should not be nil") + } + if gen.contentAnalyzer == nil { + t.Error("contentAnalyzer should not be nil") + } +} + +func TestAnalyzeEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + + email := createTestEmail() + + results := gen.AnalyzeEmail(email) + + if results == nil { + t.Fatal("Expected analysis results, got nil") + } + + if results.Email == nil { + t.Error("Email should not be nil") + } + + if results.Authentication == nil { + t.Error("Authentication should not be nil") + } +} + +func TestGenerateReport(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + testID := uuid.New() + + email := createTestEmail() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report == nil { + t.Fatal("Expected report, got nil") + } + + // Verify required fields + if report.Id == "" { + t.Error("Report ID should not be empty") + } + + // Convert testID to base32 for comparison + expectedTestID := utils.UUIDToBase32(testID) + if report.TestId != expectedTestID { + t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID) + } + + if report.Score < 0 || report.Score > 100 { + t.Errorf("Score %v is out of bounds", report.Score) + } + + if report.Summary == nil { + t.Error("Summary should not be nil") + } + + // Verify score summary (all scores are 0-100 percentages) + if report.Summary != nil { + if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 { + t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) + } + if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 { + t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) + } + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 { + t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) + } + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 { + t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) + } + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 { + t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) + } + if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 { + t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore) + } + } +} + +func TestGenerateReportWithSpamAssassin(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + testID := uuid.New() + + email := createTestEmailWithSpamAssassin() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report.Spamassassin == nil { + t.Error("SpamAssassin result should not be nil") + } + + if report.Spamassassin != nil { + if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 { + t.Error("SpamAssassin scores should be set") + } + } +} + +func TestGenerateRawEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + + tests := []struct { + name string + email *EmailMessage + expected string + }{ + { + name: "Nil email", + email: nil, + expected: "", + }, + { + name: "Email with headers only", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n", + RawBody: "", + }, + expected: "From: sender@example.com\nTo: recipient@example.com\n", + }, + { + name: "Email with headers and body", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\n", + RawBody: "This is the email body", + }, + expected: "From: sender@example.com\n\nThis is the email body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := gen.GenerateRawEmail(tt.email) + if raw != tt.expected { + t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected) + } + }) + } +} + +// Helper functions + +func createTestEmail() *EmailMessage { + header := make(mail.Header) + header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"} + header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"} + header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"} + header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"} + header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{""} + + return &EmailMessage{ + Header: header, + From: &mail.Address{Address: "sender@example.com"}, + To: []*mail.Address{{Address: "recipient@example.com"}}, + Subject: "Test Email", + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{ + { + ContentType: "text/plain", + Content: "This is a test email", + IsText: true, + }, + }, + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: \n", + RawBody: "This is a test email", + } +} + +func createTestEmailWithSpamAssassin() *EmailMessage { + email := createTestEmail() + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} + return email +} diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go new file mode 100644 index 0000000..f3f548b --- /dev/null +++ b/pkg/analyzer/rspamd.go @@ -0,0 +1,155 @@ +// 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/api" +) + +// 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{} + +// NewRspamdAnalyzer creates a new rspamd analyzer +func NewRspamdAnalyzer() *RspamdAnalyzer { + return &RspamdAnalyzer{} +} + +// AnalyzeRspamd extracts and analyzes rspamd results from email headers +func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { + headers := email.GetRspamdHeaders() + if len(headers) == 0 { + return nil + } + + result := &api.RspamdResult{ + Symbols: make(map[string]api.RspamdSymbol), + } + + // 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 + } + + // 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 *api.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 := api.RspamdSymbol{ + 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 *api.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_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..de98fe8 --- /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/api" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer() + 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() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &api.RspamdResult{ + Symbols: make(map[string]api.RspamdSymbol), + } + 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() + + 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 *api.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &api.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &api.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: &api.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &api.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &api.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer() + + 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() + 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 new file mode 100644 index 0000000..798590f --- /dev/null +++ b/pkg/analyzer/scoring.go @@ -0,0 +1,99 @@ +// 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 ( + "git.happydns.org/happyDeliver/internal/api" +) + +// ScoreToGrade converts a percentage score (0-100) to a letter grade +func ScoreToGrade(score int) string { + switch { + case score > 100: + return "A+" + case score >= 95: + return "A" + case score >= 85: + return "B" + case score >= 75: + return "C" + case score >= 65: + return "D" + case score >= 50: + return "E" + default: + return "F" + } +} + +// 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 api.ReportGrade +func ScoreToReportGrade(score int) api.ReportGrade { + return api.ReportGrade(ScoreToGrade(score)) +} + +// gradeRank returns a numeric rank for a grade (lower = worse) +func gradeRank(grade string) int { + switch grade { + 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 new file mode 100644 index 0000000..7964af2 --- /dev/null +++ b/pkg/analyzer/spamassassin.go @@ -0,0 +1,220 @@ +// 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 ( + "math" + "regexp" + "strconv" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers +type SpamAssassinAnalyzer struct{} + +// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer +func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { + return &SpamAssassinAnalyzer{} +} + +// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers +func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult { + headers := email.GetSpamAssassinHeaders() + if len(headers) == 0 { + return nil + } + + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), + } + + // Parse X-Spam-Status header + if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" { + a.parseSpamStatus(statusHeader, result) + } + + // Parse X-Spam-Score header (as fallback if not in X-Spam-Status) + if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 { + if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { + result.Score = float32(score) + } + } + + // Parse X-Spam-Flag header (as fallback) + if flagHeader, ok := headers["X-Spam-Flag"]; ok { + result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES" + } + + // 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)) + 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)) + } + + return result +} + +// 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) { + // Check if spam (first word) + parts := strings.SplitN(header, ",", 2) + if len(parts) > 0 { + firstPart := strings.TrimSpace(parts[0]) + result.IsSpam = strings.EqualFold(firstPart, "yes") + } + + // Extract score + scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`) + if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 { + if score, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Score = float32(score) + } + } + + // Extract required score + requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`) + if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 { + if required, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.RequiredScore = float32(required) + } + } + + // Extract tests + testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`) + if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 { + testsStr := matches[1] + // Tests can be comma or space separated + tests := strings.FieldsFunc(testsStr, func(r rune) bool { + return r == ',' || r == ' ' + }) + result.Tests = &tests + } +} + +// parseSpamReport parses the X-Spam-Report header to extract test details +// Format varies, but typically: +// * 1.5 TEST_NAME Description of test +// * 0.0 TEST_NAME2 Description +// Multiline descriptions continue on lines starting with * but without score: +// * 0.0 TEST_NAME Description line 1 +// * continuation line 2 +// * continuation line 3 +func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) { + segments := strings.Split(report, "*") + + // Regex to match test lines: score TEST_NAME Description + // Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description" + testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + + var currentTestName string + var currentDescription strings.Builder + + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + + // Try to match as a test line + matches := testRe.FindStringSubmatch(segment) + if len(matches) > 3 { + // Save previous test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := api.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } + + // Start new test + testName := matches[2] + score, _ := strconv.ParseFloat(matches[1], 64) + description := strings.TrimSpace(matches[3]) + + currentTestName = testName + currentDescription.Reset() + currentDescription.WriteString(description) + + // Initialize with score + result.TestDetails[testName] = api.SpamTestDetail{ + Name: testName, + Score: float32(score), + } + } else if currentTestName != "" { + // This is a continuation line for the current test + // Add a space before appending to ensure proper word separation + if currentDescription.Len() > 0 { + currentDescription.WriteString(" ") + } + currentDescription.WriteString(segment) + } + } + + // Save the last test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := api.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } +} + +// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) { + if result == nil { + return 100, "" // No spam scan results, assume good + } + + // SpamAssassin score typically ranges from -10 to +20 + // Score < 0 is very likely ham (good) + // Score 0-5 is threshold range (configurable, usually 5.0) + // Score > 5 is likely spam + + score := result.Score + + // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) + if score < 0 { + return 100, "A+" // Perfect score for ham + } else if score == 0 { + return 100, "A" // Perfect score for ham + } else if score >= result.RequiredScore { + return 0, "F" // Failed spam test + } else { + // Linear scale between 0 and required threshold + percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore)))) + return percentage, ScoreToGrade(percentage - 5) + } +} diff --git a/internal/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go similarity index 51% rename from internal/analyzer/spamassassin_test.go rename to pkg/analyzer/spamassassin_test.go index 4682ed3..b539f24 100644 --- a/internal/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -22,6 +22,7 @@ package analyzer import ( + "bytes" "net/mail" "strings" "testing" @@ -34,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) { name string header string expectedIsSpam bool - expectedScore float64 - expectedReq float64 + expectedScore float32 + expectedReq float32 expectedTests []string }{ { @@ -76,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), } analyzer.parseSpamStatus(tt.header, result) @@ -90,8 +91,12 @@ func TestParseSpamStatus(t *testing.T) { if result.RequiredScore != tt.expectedReq { t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq) } - if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) { - t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests) + if len(tt.expectedTests) > 0 { + if result.Tests == nil { + t.Errorf("Tests = nil, want %v", tt.expectedTests) + } else if !stringSliceEqual(*result.Tests, tt.expectedTests) { + t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests) + } } }) } @@ -110,27 +115,27 @@ func TestParseSpamReport(t *testing.T) { ` analyzer := NewSpamAssassinAnalyzer() - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), } analyzer.parseSpamReport(report, result) - expectedTests := map[string]SpamTestDetail{ + expectedTests := map[string]api.SpamTestDetail{ "BAYES_99": { Name: "BAYES_99", Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", + Description: api.PtrTo("Bayes spam probability is 99 to 100%"), }, "SPOOFED_SENDER": { Name: "SPOOFED_SENDER", Score: 3.5, - Description: "From address doesn't match envelope sender", + Description: api.PtrTo("From address doesn't match envelope sender"), }, "ALL_TRUSTED": { Name: "ALL_TRUSTED", Score: -1.0, - Description: "All mail servers are trusted", + Description: api.PtrTo("All mail servers are trusted"), }, } @@ -143,8 +148,8 @@ func TestParseSpamReport(t *testing.T) { if detail.Score != expected.Score { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score) } - if detail.Description != expected.Description { - t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description) + if *detail.Description != *expected.Description { + t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description) } } } @@ -152,56 +157,63 @@ func TestParseSpamReport(t *testing.T) { func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string - result *SpamAssassinResult - expectedScore float32 - minScore float32 - maxScore float32 + result *api.SpamAssassinResult + expectedScore int + minScore int + maxScore int }{ { name: "Nil result", result: nil, - expectedScore: 0.0, + expectedScore: 100, }, { name: "Excellent score (negative)", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 2.0, + expectedScore: 100, }, { name: "Good score (below threshold)", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 2.0, RequiredScore: 5.0, }, - minScore: 1.5, - maxScore: 2.0, + expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60 }, { - name: "Borderline (just above threshold)", - result: &SpamAssassinResult{ + name: "Score at threshold", + result: &api.SpamAssassinResult{ + Score: 5.0, + RequiredScore: 5.0, + }, + expectedScore: 0, // >= threshold = 0 + }, + { + name: "Above threshold (spam)", + result: &api.SpamAssassinResult{ Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 1.0, + expectedScore: 0, // >= threshold = 0 }, { name: "High spam score", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 0.5, + expectedScore: 0, // >= threshold = 0 }, { name: "Very high spam score", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0.0, + expectedScore: 0, // >= threshold = 0 }, } @@ -209,7 +221,7 @@ func TestGetSpamAssassinScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := analyzer.GetSpamAssassinScore(tt.result) + score, _ := analyzer.CalculateSpamAssassinScore(tt.result) if tt.minScore > 0 || tt.maxScore > 0 { if score < tt.minScore || score > tt.maxScore { @@ -229,7 +241,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) { name string headers map[string]string expectedIsSpam bool - expectedScore float64 + expectedScore float32 expectedHasDetails bool }{ { @@ -295,86 +307,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) { } } -func TestGenerateSpamAssassinChecks(t *testing.T) { - tests := []struct { - name string - result *SpamAssassinResult - expectedStatus api.CheckStatus - minChecks int - }{ - { - name: "Nil result", - result: nil, - expectedStatus: api.CheckStatusWarn, - minChecks: 1, - }, - { - name: "Clean email", - result: &SpamAssassinResult{ - IsSpam: false, - Score: -0.5, - RequiredScore: 5.0, - Tests: []string{"ALL_TRUSTED"}, - TestDetails: map[string]SpamTestDetail{ - "ALL_TRUSTED": { - Name: "ALL_TRUSTED", - Score: -1.5, - Description: "All mail servers are trusted", - }, - }, - }, - expectedStatus: api.CheckStatusPass, - minChecks: 2, // Main check + one test detail - }, - { - name: "Spam email", - result: &SpamAssassinResult{ - IsSpam: true, - Score: 15.0, - RequiredScore: 5.0, - Tests: []string{"BAYES_99", "SPOOFED_SENDER"}, - TestDetails: map[string]SpamTestDetail{ - "BAYES_99": { - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - "SPOOFED_SENDER": { - Name: "SPOOFED_SENDER", - Score: 3.5, - Description: "From address doesn't match envelope sender", - }, - }, - }, - expectedStatus: api.CheckStatusFail, - minChecks: 3, // Main check + two significant tests - }, - } - - analyzer := NewSpamAssassinAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateSpamAssassinChecks(tt.result) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Check main check (first one) - if len(checks) > 0 { - mainCheck := checks[0] - if mainCheck.Status != tt.expectedStatus { - t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus) - } - if mainCheck.Category != api.Spam { - t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) - } - } - }) - } -} - func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { analyzer := NewSpamAssassinAnalyzer() email := &EmailMessage{ @@ -388,95 +320,147 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { } } -func TestGenerateMainSpamCheck(t *testing.T) { +const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec +X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID, + DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED, + RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED, + SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1 +X-Spam-Level: +X-Spam-Report: + * 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query + * to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in sa-accredit.habeas.com] + * 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query + * to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in bl.score.senderscore.com] + * 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The + * query to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in sa-trusted.bondedsender.org] + * -0.0 SPF_PASS SPF: sender matches SPF record + * 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record + * -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature + * 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily + * valid + * -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's + * domain +Date: Sun, 19 Oct 2025 08:37:30 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +BODY` + +// TestAnalyzeRealEmailExample tests the analyzer with the real example email file +func TestAnalyzeRealEmailExample(t *testing.T) { + // Parse the email using the standard net/mail package + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + // Create analyzer and analyze SpamAssassin headers analyzer := NewSpamAssassinAnalyzer() + result := analyzer.AnalyzeSpamAssassin(email) - tests := []struct { - name string - score float64 - required float64 - expectedStatus api.CheckStatus - }{ - {"Excellent", -1.0, 5.0, api.CheckStatusPass}, - {"Good", 2.0, 5.0, api.CheckStatusPass}, - {"Borderline", 6.0, 5.0, api.CheckStatusWarn}, - {"High", 8.0, 5.0, api.CheckStatusWarn}, - {"Very High", 15.0, 5.0, api.CheckStatusFail}, + // Validate that we got a result + if result == nil { + t.Fatal("Expected SpamAssassin result, got nil") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - Score: tt.score, - RequiredScore: tt.required, - } - - check := analyzer.generateMainSpamCheck(result) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Message, "spam score") { - t.Error("Message should contain 'spam score'") - } - }) - } -} - -func TestGenerateTestCheck(t *testing.T) { - analyzer := NewSpamAssassinAnalyzer() - - tests := []struct { - name string - detail SpamTestDetail - expectedStatus api.CheckStatus - }{ - { - name: "High penalty test", - detail: SpamTestDetail{ - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - expectedStatus: api.CheckStatusFail, - }, - { - name: "Medium penalty test", - detail: SpamTestDetail{ - Name: "HTML_MESSAGE", - Score: 1.5, - Description: "Contains HTML", - }, - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Positive test", - detail: SpamTestDetail{ - Name: "ALL_TRUSTED", - Score: -2.0, - Description: "All mail servers are trusted", - }, - expectedStatus: api.CheckStatusPass, - }, + // Validate IsSpam flag (should be false for this email) + if result.IsSpam { + t.Error("IsSpam should be false for real_example.eml") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateTestCheck(tt.detail) + // Validate score (should be -0.1) + var expectedScore float32 = -0.1 + if result.Score != expectedScore { + t.Errorf("Score = %v, want %v", result.Score, expectedScore) + } - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Name, tt.detail.Name) { - t.Errorf("Check name should contain test name %s", tt.detail.Name) - } - }) + // Validate required score (should be 5.0) + var expectedRequired float32 = 5.0 + if result.RequiredScore != expectedRequired { + t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired) + } + + // Validate version + if result.Version == nil { + t.Errorf("Version should contain 'SpamAssassin', got: nil") + } else if !strings.Contains(*result.Version, "SpamAssassin") { + t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version) + } + + // Validate that tests were extracted + if len(*result.Tests) == 0 { + t.Error("Expected tests to be extracted, got none") + } + + // Check for expected tests from the real email + expectedTests := map[string]bool{ + "DKIM_SIGNED": true, + "DKIM_VALID": true, + "DKIM_VALID_AU": true, + "SPF_PASS": true, + "SPF_HELO_NONE": true, + } + + for _, testName := range *result.Tests { + if expectedTests[testName] { + t.Logf("Found expected test: %s", testName) + } + } + + // Validate that test details were parsed from X-Spam-Report + if len(result.TestDetails) == 0 { + t.Error("Expected test details to be parsed from X-Spam-Report, got none") + } + + // Log what we actually got for debugging + t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails)) + for name, detail := range result.TestDetails { + t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description) + } + + // Define expected test details with their scores + expectedTestDetails := map[string]float32{ + "SPF_PASS": -0.0, + "SPF_HELO_NONE": 0.0, + "DKIM_VALID": -0.1, + "DKIM_SIGNED": 0.1, + "DKIM_VALID_AU": -0.1, + "RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0, + "RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0, + "RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0, + } + + // Iterate over expected tests and verify they exist in TestDetails + for testName, expectedScore := range expectedTestDetails { + detail, ok := result.TestDetails[testName] + if !ok { + t.Errorf("Expected test %s not found in TestDetails", testName) + continue + } + if detail.Score != expectedScore { + t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore) + } + if detail.Description == nil || *detail.Description == "" { + t.Errorf("Test %s should have a description", testName) + } + } + + // Test GetSpamAssassinScore + score, _ := analyzer.CalculateSpamAssassinScore(result) + if score != 100 { + t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) } } diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts index b1719e9..dfe34de 100644 --- a/web/openapi-ts.config.ts +++ b/web/openapi-ts.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [ { name: "@hey-api/client-fetch", - runtimeConfigPath: "./src/lib/hey-api.ts", + runtimeConfigPath: "$lib/hey-api.ts", }, ], }); diff --git a/web/package-lock.json b/web/package-lock.json index 48bb286..835218b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,17 +13,17 @@ "bootstrap-icons": "^1.13.1" }, "devDependencies": { - "@eslint/compat": "^1.4.0", + "@eslint/compat": "^2.0.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@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", + "@types/node": "^24.0.0", + "eslint": "^9.38.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", @@ -34,18 +34,10 @@ "vitest": "^3.2.4" } }, - "node_modules/@bufbuild/protobuf": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", - "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", - "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -60,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -77,9 +69,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -94,9 +86,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -111,9 +103,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -128,9 +120,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -145,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -162,9 +154,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -179,9 +171,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -196,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -213,9 +205,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -230,9 +222,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -247,9 +239,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -264,9 +256,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -281,9 +273,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -298,9 +290,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -315,9 +307,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -332,9 +324,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -349,9 +341,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -366,9 +358,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -383,9 +375,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -400,9 +392,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -417,9 +409,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -434,9 +426,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -451,9 +443,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -468,9 +460,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -485,9 +477,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -517,9 +509,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": { @@ -527,19 +519,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.2", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz", + "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^1.1.0" }, "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": { @@ -548,13 +540,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -563,22 +555,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "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==", + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -588,10 +580,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -601,7 +606,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -626,9 +631,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -639,9 +644,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -649,27 +654,40 @@ } }, "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.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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/@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" @@ -679,9 +697,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": { @@ -698,27 +716,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" @@ -836,354 +854,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1203,9 +873,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -1217,9 +887,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -1231,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -1245,9 +915,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -1259,9 +929,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -1273,9 +943,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -1287,9 +957,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -1301,9 +971,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -1315,9 +985,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -1329,9 +999,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -1343,9 +1013,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -1357,9 +1041,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -1371,9 +1069,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -1385,9 +1083,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -1399,9 +1097,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -1413,9 +1111,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -1427,9 +1125,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -1440,10 +1138,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -1455,9 +1167,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -1469,9 +1181,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -1483,9 +1195,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -1497,9 +1209,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -1511,16 +1223,16 @@ ] }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1538,9 +1250,9 @@ } }, "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.50.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz", + "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", "dev": true, "license": "MIT", "peer": true, @@ -1550,7 +1262,7 @@ "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.3.2", + "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -1569,26 +1281,30 @@ "@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", "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true + }, + "typescript": { + "optional": true } } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "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": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "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", + "magic-string": "^0.30.21", + "obug": "^2.1.0", "vitefu": "^1.1.1" }, "engines": { @@ -1600,13 +1316,13 @@ } }, "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==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.1" + "obug": "^2.1.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24" @@ -1618,13 +1334,14 @@ } }, "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": { @@ -1656,32 +1373,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==", + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.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": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1691,7 +1407,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", + "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1707,18 +1423,18 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1733,15 +1449,15 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1755,14 +1471,14 @@ } }, "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.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1773,9 +1489,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", "dev": true, "license": "MIT", "engines": { @@ -1790,17 +1506,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1815,9 +1531,9 @@ } }, "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.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -1829,22 +1545,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "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" @@ -1883,17 +1598,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1908,13 +1636,13 @@ } }, "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.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2197,27 +1925,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "dev": true, - "license": "MIT/X11", - "optional": true - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2235,19 +1942,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", @@ -2318,9 +2025,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2393,22 +2100,14 @@ "color-support": "bin.js" } }, - "node_modules/colorjs.io": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", - "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "dev": true, - "license": "MIT", - "optional": true - }, "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": { @@ -2519,9 +2218,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.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", "dev": true, "license": "MIT", "dependencies": { @@ -2536,9 +2235,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": { @@ -2575,24 +2274,10 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "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.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "dev": true, "license": "MIT" }, @@ -2617,9 +2302,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2630,32 +2315,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escape-string-regexp": { @@ -2672,26 +2357,25 @@ } }, "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "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.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -2750,9 +2434,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.12.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", - "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz", + "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2765,7 +2449,7 @@ "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", - "svelte-eslint-parser": "^1.3.0" + "svelte-eslint-parser": "^1.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2783,6 +2467,19 @@ } } }, + "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", @@ -2813,6 +2510,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2839,9 +2549,9 @@ } }, "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": { @@ -2852,9 +2562,9 @@ } }, "node_modules/esrap": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", - "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2905,9 +2615,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": { @@ -2915,9 +2625,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" }, @@ -2928,36 +2638,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", @@ -2972,14 +2652,22 @@ "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==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/file-entry-cache": { @@ -2995,19 +2683,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", @@ -3093,9 +2768,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.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -3105,13 +2780,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", @@ -3154,14 +2822,6 @@ "node": ">= 4" } }, - "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3247,16 +2907,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", @@ -3308,9 +2958,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3416,9 +3066,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "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" }, @@ -3437,39 +3087,15 @@ "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", @@ -3553,14 +3179,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -3569,32 +3187,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.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", + "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==", "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/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "node_modules/nypm/node_modules/citty": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", + "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", "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", @@ -3603,16 +3230,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" @@ -3722,9 +3349,9 @@ } }, "node_modules/perfect-debounce": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", - "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "dev": true, "license": "MIT" }, @@ -3736,13 +3363,14 @@ "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", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3885,9 +3513,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": { @@ -3909,9 +3537,9 @@ } }, "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, @@ -3926,9 +3554,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.4.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", + "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3946,27 +3574,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", @@ -4002,21 +3609,10 @@ "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.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4030,28 +3626,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -4068,41 +3667,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/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -4116,395 +3680,6 @@ "node": ">=6" } }, - "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-embedded": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", - "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@bufbuild/protobuf": "^2.5.0", - "buffer-builder": "^0.2.0", - "colorjs.io": "^0.5.0", - "immutable": "^5.0.2", - "rxjs": "^7.4.0", - "supports-color": "^8.1.1", - "sync-child-process": "^1.0.2", - "varint": "^6.0.0" - }, - "bin": { - "sass": "dist/bin/sass.js" - }, - "engines": { - "node": ">=16.0.0" - }, - "optionalDependencies": { - "sass-embedded-all-unknown": "1.93.2", - "sass-embedded-android-arm": "1.93.2", - "sass-embedded-android-arm64": "1.93.2", - "sass-embedded-android-riscv64": "1.93.2", - "sass-embedded-android-x64": "1.93.2", - "sass-embedded-darwin-arm64": "1.93.2", - "sass-embedded-darwin-x64": "1.93.2", - "sass-embedded-linux-arm": "1.93.2", - "sass-embedded-linux-arm64": "1.93.2", - "sass-embedded-linux-musl-arm": "1.93.2", - "sass-embedded-linux-musl-arm64": "1.93.2", - "sass-embedded-linux-musl-riscv64": "1.93.2", - "sass-embedded-linux-musl-x64": "1.93.2", - "sass-embedded-linux-riscv64": "1.93.2", - "sass-embedded-linux-x64": "1.93.2", - "sass-embedded-unknown-all": "1.93.2", - "sass-embedded-win32-arm64": "1.93.2", - "sass-embedded-win32-x64": "1.93.2" - } - }, - "node_modules/sass-embedded-all-unknown": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", - "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", - "cpu": [ - "!arm", - "!arm64", - "!riscv64", - "!x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-android-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", - "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", - "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", - "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", - "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", - "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", - "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", - "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", - "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", - "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", - "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", - "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", - "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", - "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", - "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-unknown-all": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", - "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "!android", - "!darwin", - "!linux", - "!win32" - ], - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", - "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", - "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -4519,9 +3694,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": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "dev": true, "license": "MIT" }, @@ -4644,9 +3819,9 @@ } }, "node_modules/svelte": { - "version": "5.41.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.41.0.tgz", - "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==", + "version": "5.49.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz", + "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", "dev": true, "license": "MIT", "peer": true, @@ -4659,8 +3834,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.6.2", "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", @@ -4671,9 +3847,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.3.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.6.tgz", + "integrity": "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4694,28 +3870,10 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/svelte-eslint-parser": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", - "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", + "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", "dev": true, "license": "MIT", "dependencies": { @@ -4727,7 +3885,8 @@ "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -4741,31 +3900,6 @@ } } }, - "node_modules/sync-child-process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", - "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "sync-message-port": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4774,11 +3908,14 @@ "license": "MIT" }, "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==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4797,38 +3934,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "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", - "peer": true, - "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", @@ -4859,19 +3964,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4883,9 +3975,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.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -4895,14 +3987,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4932,16 +4016,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.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", "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.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4970,9 +4054,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" }, @@ -4993,23 +4077,15 @@ "dev": true, "license": "MIT" }, - "node_modules/varint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, - "license": "MIT", - "optional": true - }, "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": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -5100,38 +4176,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "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", - "peer": true, - "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", @@ -5225,18 +4269,12 @@ } } }, - "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==", + "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==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", @@ -5288,6 +4326,40 @@ "dev": true, "license": "MIT" }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index d0a2578..e5b88f2 100644 --- a/web/package.json +++ b/web/package.json @@ -16,17 +16,17 @@ "generate:api": "openapi-ts" }, "devDependencies": { - "@eslint/compat": "^1.4.0", + "@eslint/compat": "^2.0.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.85.2", + "@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", + "@types/node": "^24.0.0", + "eslint": "^9.38.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", diff --git a/web/routes.go b/web/routes.go index 754c1b2..876954c 100644 --- a/web/routes.go +++ b/web/routes.go @@ -23,9 +23,10 @@ package web import ( "encoding/json" + "flag" + "fmt" "io" "io/fs" - "io/ioutil" "log" "net/http" "net/url" @@ -41,12 +42,34 @@ import ( var ( indexTpl *template.Template + CustomBodyHTML = "" CustomHeadHTML = "" ) +func init() { + flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before ") + flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before ") +} + func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig := map[string]interface{}{} + if cfg.ReportRetention > 0 { + appConfig["report_retention"] = cfg.ReportRetention + } + + if cfg.SurveyURL.Host != "" { + appConfig["survey_url"] = cfg.SurveyURL.String() + } + + if len(cfg.Analysis.RBLs) > 0 { + appConfig["rbls"] = cfg.Analysis.RBLs + } + + if cfg.CustomLogoURL != "" { + appConfig["custom_logo_url"] = cfg.CustomLogoURL + } + if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { log.Println("Unable to generate JSON config to inject in web application") } else { @@ -66,6 +89,12 @@ 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("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/img/*path", serveOrReverse("", cfg)) @@ -85,7 +114,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { if u, err := url.Parse(cfg.DevProxy); err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) } else { - if forced_url != "" { + if forced_url != "" && forced_url != "/" { u.Path = path.Join(u.Path, forced_url) } else { u.Path = path.Join(u.Path, c.Request.URL.Path) @@ -114,14 +143,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { } } - v, _ := ioutil.ReadAll(resp.Body) + v, _ := io.ReadAll(resp.Body) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } @@ -139,16 +170,18 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { if indexTpl == nil { // Create template from file f, _ := Assets.Open("index.html") - v, _ := ioutil.ReadAll(f) + v, _ := io.ReadAll(f) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) } // Serve template if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ - "Head": CustomHeadHTML, + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), }); err != nil { log.Println("Unable to return index.html:", err.Error()) } diff --git a/web/src/app.css b/web/src/app.css index ddae5b6..dca80a5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,6 +1,9 @@ :root { --bs-primary: #1cb487; --bs-primary-rgb: 28, 180, 135; + --bs-link-color-rgb: 28, 180, 135; + --bs-link-hover-color-rgb: 17, 112, 84; + --bs-tertiary-bg: #e7e8e8; } body { @@ -8,6 +11,10 @@ body { -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } +.bg-tertiary { + background-color: var(--bs-tertiary-bg); +} + /* Animations */ @keyframes fadeIn { from { @@ -74,14 +81,21 @@ body { /* Custom card styling */ .card { - border: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease; } -.card:hover { +.card:not(.fade-in .card) { + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.fade-in .card:not(.card .card) { + border: none; +} + +.card:hover:not(.fade-in .card) { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } diff --git a/web/src/app.html b/web/src/app.html index 1966776..9e3bf88 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -3,9 +3,38 @@ + + + + + + + + + + + + + %sveltekit.head% +
%sveltekit.body%
diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..fb235b0 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + h + + + + + diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte new file mode 100644 index 0000000..93531e7 --- /dev/null +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -0,0 +1,539 @@ + + +
+
+

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

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

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

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

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

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

{check.message}

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

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

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

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

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

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

+ +
+ + +
+ Status: + {#if dmarcRecord.valid} + Valid + {:else} + Invalid + {/if} +
+ + + {#if dmarcRecord.policy} +
+ Policy: + + {dmarcRecord.policy} + + {#if dmarcRecord.policy === "reject"} +
+ + Maximum protection — emails failing DMARC checks are rejected. + This provides the strongest defense against spoofing and phishing. +
+ {:else if dmarcRecord.policy === "quarantine"} +
+ + Good protection — emails failing DMARC checks are + quarantined (sent to spam). This is a safe middle ground.
+ + Once you've validated your configuration and ensured all legitimate mail + passes, consider upgrading to p=reject for maximum protection. +
+ {:else if dmarcRecord.policy === "none"} +
+ + Monitoring only — emails failing DMARC are delivered + normally. This is only recommended during initial setup.
+ + After monitoring reports, upgrade to p=quarantine or + p=reject to actively protect your domain. +
+ {:else} +
+ + Unknown policy — the policy value is not recognized. Valid + options are: none, quarantine, or reject. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.subdomain_policy} + {@const mainStrength = policyStrength(dmarcRecord.policy)} + {@const subStrength = policyStrength(dmarcRecord.subdomain_policy)} +
+ Subdomain Policy: + + {dmarcRecord.subdomain_policy} + + {#if subStrength >= mainStrength} +
+ + Good configuration — subdomain policy is equal to or stricter + than main policy. +
+ {:else} +
+ + Weaker subdomain protection — consider setting + sp={dmarcRecord.policy} to match your main policy for consistent + protection. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Subdomain Policy: + Inherits main policy +
+ + Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection. +
+
+ {/if} + + + {#if dmarcRecord.percentage !== undefined} +
+ Enforcement Percentage: + + {dmarcRecord.percentage}% + + {#if dmarcRecord.percentage === 100} +
+ + Full enforcement — all messages are subject to DMARC policy. + This provides maximum protection. +
+ {:else if dmarcRecord.percentage >= 50} +
+ + Partial enforcement — only {dmarcRecord.percentage}% of + messages are subject to DMARC policy. Consider increasing to + pct=100 once you've validated your configuration. +
+ {:else} +
+ + Low enforcement — only {dmarcRecord.percentage}% of + messages are protected. Gradually increase to pct=100 for full + protection. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Enforcement Percentage: + 100% (default) +
+ + Full enforcement — all messages are subject to DMARC policy + by default. +
+
+ {/if} + + + {#if dmarcRecord.spf_alignment} +
+ SPF Alignment: + + {dmarcRecord.spf_alignment} + + {#if dmarcRecord.spf_alignment === "relaxed"} +
+ + Recommended for most senders — ensures legitimate + subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are + accepted. Ensure all legitimate mail comes from the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.dkim_alignment} +
+ DKIM Alignment: + + {dmarcRecord.dkim_alignment} + + {#if dmarcRecord.dkim_alignment === "relaxed"} +
+ + Recommended for most senders — ensures legitimate + subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are + accepted. Ensure all DKIM signatures use the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.record} +
+ Record:
+ {dmarcRecord.record} +
+ {/if} + + + {#if dmarcRecord.error} +
+ Error: + {dmarcRecord.error} +
+ {/if} +
+
+{/if} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte new file mode 100644 index 0000000..b7997b0 --- /dev/null +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -0,0 +1,174 @@ + + +
+
+

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

+
+
+ {#if !dnsResults} +

No DNS results available

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

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

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

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

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

+ From Domain: {dnsResults.from_domain} +

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

+ + Email Path +

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

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

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

{status}

+ + +

{getErrorTitle(status)}

+ + +

{getErrorDescription(status)}

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

Looking for something specific?

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

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

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

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

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

{description}

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

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

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

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

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

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

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

    + + + 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} + +
    SymbolScoreParameters
    {symbolName} + 0 + ? "text-danger fw-bold" + : symbol.score < 0 + ? "text-success fw-bold" + : "text-muted"} + > + {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} + + {symbol.params ?? ""}
    +
    +
    + {/if} + + {#if rspamd.report} +
    + Raw Report +
    {rspamd.report}
    +
    + {/if} +
    +
    + + diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 65aa706..7a80dc4 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,71 +1,162 @@ -
    +
    -

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

    -

    {getScoreLabel(score)}

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

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

    Overall Deliverability Score

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

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

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

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

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

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

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

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

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

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

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

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

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

    + + + Whitelist Checks + + Informational +

    +
    +
    +

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

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

    {status}

    - - -

    {getErrorTitle(status)}

    - - -

    {getErrorDescription(status)}

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

    Looking for something specific?

    - -
    - {/if} -
    -
    +
    - - diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 9ed83d4..077f340 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,24 +1,58 @@ + + + +
    -
    + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index f0709a1..7c23d10 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,7 +1,9 @@ - happyDeliver - Email Deliverability Testing + happyDeliver. Test Your Email Deliverability. -
    +
    @@ -108,7 +152,7 @@ and more. Open-source, self-hosted, and privacy-focused.

    -
    +

    Comprehensive Email Analysis

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

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

    + + Check IP Blacklist Status +

    +

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

    +
    + + +
    +
    +

    Enter IP Address

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

    + + What's Checked +

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

    + + Why Check Blacklists? +

    +

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

    +

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

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

    + + Need Complete Email Analysis? +

    +

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

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

    + + Blacklist Analysis +

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

    Checking {ip}...

    +

    Querying DNS-based blacklists

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

    Check Failed

    +

    {error}

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

    + {result.ip} +

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

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

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

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

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

    + + What This Means +

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

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

    + {:else} +

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

    +
    +

    Recommended Actions:

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

    + + Want Complete Email Analysis? +

    +

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

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

    + + Test Domain Configuration +

    +

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

    +
    + + +
    +
    +

    Enter Domain Name

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

    + + What's Checked +

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

    + + Need More? +

    +

    + For complete email deliverability analysis including: +

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

    + + Domain Analysis +

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

    Analyzing {domain}...

    +

    Checking DNS records and configuration

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

    Analysis Failed

    +

    {error}

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

    + {result.domain} +

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

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

    +
    + {:else} +

    Domain Configuration Score

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

    + + Want Complete Email Analysis? +

    +

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

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

    Loading test...

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

    Detailed Checks

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