diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3e1579 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Git files +.git +.gitignore + +# Documentation +*.md +!README.md + +# Build artifacts +happyDeliver +*.db +*.sqlite +*.sqlite3 + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs files +logs/ + +# Test files +*_test.go +testdata/ diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..4984d45 --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} +{{/if}} +manifests: + - image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + platform: + architecture: amd64 + os: linux + - image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + platform: + architecture: arm + os: linux + variant: v7 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..779952f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,156 @@ +--- +kind: pipeline +type: docker +name: build-arm64 + +platform: + os: linux + arch: arm64 + +steps: +- name: frontend + image: node:24-alpine + commands: + - cd web + - npm install --network-timeout=100000 + - npm run generate:api + - npm run build + +- name: backend-commit + image: golang:1-alpine + commands: + - apk add --no-cache git + - go generate ./... + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver + - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver + environment: + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + +- name: backend-tag + image: golang:1-alpine + commands: + - apk add --no-cache git + - go generate ./... + - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver + environment: + CGO_ENABLED: 0 + when: + event: + - tag + +- name: build-commit macOS + image: golang:1-alpine + commands: + - apk add --no-cache git + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + environment: + CGO_ENABLED: 0 + GOOS: darwin + GOARCH: arm64 + when: + event: + exclude: + - tag + +- name: build-tag macOS + image: golang:1-alpine + commands: + - apk add --no-cache git + - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + environment: + CGO_ENABLED: 0 + GOOS: darwin + GOARCH: arm64 + when: + event: + - tag + +- name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/happydeliver + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +--- +kind: pipeline +type: docker +name: build-amd64 + +platform: + os: linux + arch: amd64 + +steps: +- name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/happydeliver + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +--- +kind: pipeline +name: docker-manifest + +platform: + os: linux + arch: arm64 + +steps: +- name: publish on Docker Hub + image: plugins/manifest + settings: + auto_tag: true + ignore_missing: true + spec: .drone-manifest.yml + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +depends_on: +- build-amd64 +- build-arm64 diff --git a/.gitignore b/.gitignore index 223cf99..7ece05e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ vendor/ .env.local *.local +# Logs files +logs/ + # Database files *.db *.sqlite diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9626813 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,189 @@ +# Multi-stage Dockerfile for happyDeliver with integrated MTA +# Stage 1: Build the Svelte application +FROM node:24-alpine AS nodebuild + +WORKDIR /build + +COPY api/ api/ +COPY web/ web/ + +RUN yarn --cwd web install && \ + yarn --cwd web run generate:api && \ + yarn --cwd web --offline build + +# Stage 2: Build the Go application +FROM golang:1-alpine AS builder + +WORKDIR /build + +# Install build dependencies +RUN apk add --no-cache ca-certificates git gcc musl-dev + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . +COPY --from=nodebuild /build/web/build/ ./web/build/ + +# Build the application +RUN go generate ./... && \ + CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver + +# Stage 3: Prepare perl and spamass-milt +FROM alpine:3 AS pl + +RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ + build-base \ + libmilter-dev \ + musl-obstack-dev \ + openssl \ + openssl-dev \ + perl-app-cpanminus \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-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 echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ + apk add --no-cache \ + bash \ + ca-certificates \ + libmilter \ + openssl \ + perl \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-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 \ + rspamd \ + spamassassin \ + spamassassin-client \ + supervisor \ + sqlite \ + tzdata \ + && rm -rf /var/cache/apk/* + +# Copy Mail::Milter::Authentication and its dependancies +COPY --from=pl /usr/local/ /usr/local/ + +# Create happydeliver user and group +RUN addgroup -g 1000 happydeliver && \ + adduser -D -u 1000 -G happydeliver happydeliver + +# Create necessary directories +RUN mkdir -p /etc/happydeliver \ + /var/lib/happydeliver \ + /var/log/happydeliver \ + /var/cache/authentication_milter \ + /var/lib/authentication_milter \ + /var/spool/postfix/authentication_milter \ + /var/spool/postfix/spamassassin \ + /var/spool/postfix/rspamd \ + && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ + && chown rspamd:mail /var/spool/postfix/rspamd \ + && chmod 750 /var/spool/postfix/rspamd + +# Copy the built application +COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver +RUN chmod +x /usr/local/bin/happyDeliver + +# Copy configuration files +COPY docker/postfix/ /etc/postfix/ +COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json +COPY docker/spamassassin/ /etc/mail/spamassassin/ +COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ +COPY docker/supervisor/ /etc/supervisor/ +COPY docker/entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +# Expose ports +# 25 - SMTP +# 8080 - API server +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 + +# 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 new file mode 100644 index 0000000..3c213cd --- /dev/null +++ b/README.md @@ -0,0 +1,285 @@ +# 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, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more +- **REST API**: Full-featured API for creating tests and retrieving reports +- **LMTP Server**: Built-in LMTP server for seamless MTA integration +- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers +- **Database Storage**: SQLite or PostgreSQL support +- **Configurable**: via environment or config file for all settings + +![A sample deliverability report](web/static/img/report.webp) + +## Quick Start + +### With Docker (Recommended) + +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 +- **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 + +#### 1. Using docker-compose + +```bash +# Clone the repository +git clone https://git.nemunai.re/happyDomain/happyDeliver.git +cd happydeliver + +# Edit docker-compose.yml to set your domain +# Change HAPPYDELIVER_DOMAIN environment variable and hostname + +# Build and start +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop +docker-compose down +``` + +The API will be available at `http://localhost:8080` and SMTP at `localhost:25`. + +#### 2. Using docker build directly + +```bash +# Build the image +docker build -t happydeliver:latest . + +# Run the container +docker run -d \ + --name happydeliver \ + -p 25:25 \ + -p 8080:8080 \ + -e HAPPYDELIVER_DOMAIN=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 + +```bash +go generate +go build -o happyDeliver ./cmd/happyDeliver +``` + +### 2. Run the API Server + +```bash +./happyDeliver server +``` + +The server will start on `http://localhost:8080` by default. + +#### 3. Integrate with your existing e-mail setup + +It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... +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: + +#### Postfix LMTP Transport + +You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP. + +1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`): + + ```bash + ./happyDeliver server + ``` + + You can customize the LMTP address with the `-lmtp-addr` flag or in the config file. + +2. Create the file `/etc/postfix/transport_happydeliver` with the following content: + + ``` + # Transport map - route test emails to happyDeliver LMTP server + # Pattern: test-@yourdomain.com -> LMTP on localhost: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`: + + ```diff + -transport_maps = texthash:/etc/postfix/transport + +transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver + ``` + + If your `transport_maps` option is not set, just append this line: + + ``` + transport_maps = pcre:/etc/postfix/transport_happydeliver + ``` + + Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. + +4. Reload Postfix configuration: + + ```bash + postfix reload + ``` + +#### 4. Create a Test + +```bash +curl -X POST http://localhost:8080/api/test +``` + +Response: +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost", + "status": "pending", + "message": "Send your test email to the address above" +} +``` + +#### 5. Send Test Email + +Send a test email to the address provided (you'll need to configure your MTA to route emails to the analyzer - see MTA Integration below). + +#### 6. Get Report + +```bash +curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/test` | POST | Create a new deliverability test | +| `/api/test/{id}` | GET | Get test metadata and status | +| `/api/report/{id}` | GET | Get detailed analysis report | +| `/api/report/{id}/raw` | GET | Get raw annotated email | +| `/api/status` | GET | Service health and status | + +## Email Analyzer (CLI Mode) + +For manual testing or debugging, you can analyze emails from the command line: + +```bash +cat email.eml | ./happyDeliver analyze +``` + +Or specify recipient explicitly: + +```bash +cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com +``` + +**Note:** In production, emails are delivered via LMTP (see integration instructions above). + +## Scoring System + +The deliverability score is calculated from A to F based on: + +- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records +- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation +- **Blacklist**: RBL/DNSBL checks +- **Headers**: Required headers, MIME structure, Domain alignment +- **Spam**: SpamAssassin and rspamd scores (combined 50/50) +- **Content**: HTML quality, links, images, unsubscribe + +## Funding + +This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/happyDomain). + +[NLnet foundation logo](https://nlnet.nl) +[NGI Zero Logo](https://nlnet.nl/core) + +## License + +GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) diff --git a/api/openapi.yaml b/api/openapi.yaml index f027f1a..e989261 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -31,11 +31,11 @@ paths: tags: - tests summary: Create a new deliverability test - description: Generates a unique test email address for sending test emails + description: Generates a unique test email address for sending test emails. No database record is created until an email is received. operationId: createTest responses: '201': - description: Test created successfully + description: Test email address generated successfully content: application/json: schema: @@ -51,8 +51,8 @@ paths: get: tags: - tests - summary: Get test metadata - description: Retrieve test status and metadata + summary: Get test status + description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -60,16 +60,17 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': - description: Test metadata retrieved successfully + description: Test status retrieved successfully content: application/json: schema: $ref: '#/components/schemas/Test' - '404': - description: Test not found + '500': + description: Internal server error content: application/json: schema: @@ -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, received, analyzed, failed] - description: Current test status + 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 3dc3fae..3caf4d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -22,31 +22,50 @@ package main import ( + "flag" "fmt" "log" "os" + + "git.happydns.org/happyDeliver/internal/app" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/version" ) func main() { - fmt.Println("Mail Tester - Email Deliverability Testing Platform") - fmt.Println("Version: 0.1.0-dev") + fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform") + fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version) - if len(os.Args) < 2 { - printUsage() - os.Exit(1) + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err.Error()) } - command := os.Args[1] + command := flag.Arg(0) switch command { case "server": - log.Println("Starting API server...") - // TODO: Start API server + if err := app.RunServer(cfg); err != nil { + log.Fatalf("Server error: %v", err) + } case "analyze": - log.Println("Starting email analyzer...") - // TODO: Start email analyzer (LMTP/pipe mode) + 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("0.1.0-dev") + fmt.Println(version.Version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -55,8 +74,12 @@ func main() { } func printUsage() { - fmt.Println("\nUsage:") - fmt.Println(" mailtester server - Start the API server") - fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)") - fmt.Println(" mailtester version - Print version information") + fmt.Println("\nCommand availables:") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") + fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") + fmt.Println(" happyDeliver version - Print version information") + fmt.Println("") + flag.Usage() } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ccfd313 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + happydeliver: + build: + context: . + dockerfile: Dockerfile + image: happydomain/happydeliver:latest + container_name: happydeliver + # Set a hostname + hostname: mail.happydeliver.local + + environment: + # Set your domain + HAPPYDELIVER_DOMAIN: happydeliver.local + + ports: + # SMTP port + - "25:25" + # API port + - "8080:8080" + + volumes: + # Persistent database storage + - ./data:/var/lib/happydeliver + # Log files + - ./logs:/var/log/happydeliver + + restart: unless-stopped + +volumes: + data: + logs: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..3769365 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,165 @@ +# happyDeliver Docker Configuration + +This directory contains all configuration files for the all-in-one Docker container. + +## Architecture + +The Docker container integrates multiple components: + +- **Postfix**: Mail Transfer Agent (MTA) that receives emails on port 25 +- **OpenDKIM**: DKIM signature verification +- **OpenDMARC**: DMARC policy validation +- **SpamAssassin**: Spam scoring and content analysis +- **happyDeliver**: Go application (API server + email analyzer) +- **Supervisor**: Process manager that runs all services + +## Directory Structure + +``` +docker/ +├── postfix/ +│ ├── main.cf # Postfix main configuration +│ ├── master.cf # Postfix service definitions +│ └── transport_maps # Email routing rules +├── opendkim/ +│ └── opendkim.conf # DKIM verification config +├── opendmarc/ +│ └── opendmarc.conf # DMARC validation config +├── spamassassin/ +│ └── local.cf # SpamAssassin rules and scoring +├── supervisor/ +│ └── supervisord.conf # Supervisor service definitions +├── entrypoint.sh # Container initialization script +└── config.docker.yaml # happyDeliver default config +``` + +## Configuration Details + +### Postfix (postfix/) + +**main.cf**: Core Postfix settings +- Configures hostname, domain, and network interfaces +- Sets up milter integration for OpenDKIM and OpenDMARC +- Configures SPF policy checking +- Routes emails through SpamAssassin content filter +- Uses transport_maps to route test emails to happyDeliver + +**master.cf**: Service definitions +- Defines SMTP service with content filtering +- Sets up SPF policy service (postfix-policyd-spf-perl) +- Configures SpamAssassin content filter +- Defines happydeliver pipe for email analysis + +**transport_maps**: PCRE-based routing +- Matches test-UUID@domain emails +- Routes them to the happydeliver pipe + +### OpenDKIM (opendkim/) + +**opendkim.conf**: DKIM verification settings +- Operates in verification-only mode +- Adds Authentication-Results headers +- Socket communication with Postfix via milter +- 5-second DNS timeout + +### OpenDMARC (opendmarc/) + +**opendmarc.conf**: DMARC validation settings +- Validates DMARC policies +- Adds results to Authentication-Results headers +- Does not reject emails (analysis mode only) +- Socket communication with Postfix via milter + +### SpamAssassin (spamassassin/) + +**local.cf**: Spam detection rules +- Enables network tests (RBL checks) +- SPF and DKIM checking +- Required score: 5.0 (standard threshold) +- Adds detailed spam report headers +- 5-second RBL timeout + +### Supervisor (supervisor/) + +**supervisord.conf**: Service orchestration +- Runs all services as daemons +- Start order: OpenDKIM → OpenDMARC → SpamAssassin → Postfix → API +- Automatic restart on failure +- Centralized logging + +### Entrypoint Script (entrypoint.sh) + +Initialization script that: +1. Creates required directories and sets permissions +2. Replaces configuration placeholders with environment variables +3. Initializes Postfix (aliases, transport maps) +4. Updates SpamAssassin rules +5. Starts Supervisor to launch all services + +### happyDeliver Config (config.docker.yaml) + +Default configuration for the Docker environment: +- API server on 0.0.0.0:8080 +- SQLite database at /var/lib/happydeliver/happydeliver.db +- Configurable domain for test emails +- RBL servers for blacklist checking +- Timeouts for DNS and HTTP checks + +## Environment Variables + +The container accepts these environment variables: + +- `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 HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... +``` + +## Volumes + +**Required volumes:** +- `/var/lib/happydeliver`: Database and persistent data +- `/var/log/happydeliver`: Log files from all services + +**Optional volumes:** +- `/etc/happydeliver/config.yaml`: Custom configuration file + +## Ports + +- **25**: SMTP (Postfix) +- **8080**: HTTP API (happyDeliver) + +## Service Startup Order + +Supervisor ensures services start in the correct order: + +1. **OpenDKIM** (priority 10): DKIM verification milter +2. **OpenDMARC** (priority 11): DMARC validation milter +3. **SpamAssassin** (priority 12): Spam scoring daemon +4. **Postfix** (priority 20): MTA that uses the above services +5. **happyDeliver API** (priority 30): REST API server + +## Email Processing Flow + +1. Email arrives at Postfix on port 25 +2. Postfix sends to OpenDKIM milter + - Verifies DKIM signature + - Adds `Authentication-Results: ... dkim=pass/fail` +3. Postfix sends to OpenDMARC milter + - Validates DMARC policy + - Adds `Authentication-Results: ... dmarc=pass/fail` +4. Postfix routes through SpamAssassin content filter + - Checks SPF record + - Scores email for spam + - Adds `X-Spam-Status` and `X-Spam-Report` headers +5. Postfix checks transport_maps + - If recipient matches test-UUID pattern, route to happydeliver pipe +6. happyDeliver analyzer receives email + - Extracts test ID from recipient + - Parses all headers added by filters + - Performs additional analysis (DNS, RBL, content) + - Generates deliverability score + - Stores report in database 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 new file mode 100644 index 0000000..ef45b61 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -e + +echo "Starting happyDeliver container..." + +# Get environment variables with defaults +[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) +HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" + +echo "Hostname: $HOSTNAME" +echo "Domain: $HAPPYDELIVER_DOMAIN" + +# Create socket directories +mkdir -p /var/spool/postfix/authentication_milter +chown mail:mail /var/spool/postfix/authentication_milter +chmod 750 /var/spool/postfix/authentication_milter + +mkdir -p /var/spool/postfix/rspamd +chown rspamd:mail /var/spool/postfix/rspamd +chmod 750 /var/spool/postfix/rspamd + +# Create log directory +mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter +chown happydeliver:happydeliver /var/log/happydeliver +chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter + +# Replace placeholders in Postfix configuration +echo "Configuring Postfix..." +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf +sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf + +# Add certificates to postfix +[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && { + cat <> /etc/postfix/main.cf +smtpd_tls_cert_file = ${POSTFIX_CERT_FILE} +smtpd_tls_key_file = ${POSTFIX_KEY_FILE} +smtpd_tls_security_level = may +EOF +} + +# Replace placeholders in configurations +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json + +# Initialize Postfix aliases +if [ -f /etc/postfix/aliases ]; then + echo "Initializing Postfix aliases..." + postalias /etc/postfix/aliases || true +fi + +# Compile transport maps +if [ -f /etc/postfix/transport_maps ]; then + echo "Compiling transport maps..." + postmap /etc/postfix/transport_maps +fi + +# Update SpamAssassin rules +echo "Updating SpamAssassin rules..." +sa-update || echo "SpamAssassin rules update failed (might be first run)" + +# Compile SpamAssassin rules +sa-compile || echo "SpamAssassin compilation skipped" + +# Initialize database if it doesn't exist +if [ ! -f /var/lib/happydeliver/happydeliver.db ]; then + echo "Database will be initialized on first API startup..." +fi + +# Set proper permissions +chown -R happydeliver:happydeliver /var/lib/happydeliver + +echo "Configuration complete, starting services..." + +# Execute the main command (supervisord) +exec "$@" diff --git a/docker/postfix/aliases b/docker/postfix/aliases new file mode 100644 index 0000000..e910b5d --- /dev/null +++ b/docker/postfix/aliases @@ -0,0 +1,10 @@ +# Postfix aliases for happyDeliver +# This file is processed by postalias to create aliases.db + +# Standard aliases +postmaster: root +abuse: root +mailer-daemon: postmaster + +# Root mail can be redirected if needed +# root: admin@example.com diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf new file mode 100644 index 0000000..5a73fb3 --- /dev/null +++ b/docker/postfix/main.cf @@ -0,0 +1,40 @@ +# Postfix main configuration for happyDeliver +# This configuration receives emails and routes them through authentication filters + +# Basic settings +compatibility_level = 3.6 +myhostname = __HOSTNAME__ +mydomain = __DOMAIN__ +myorigin = $mydomain +inet_interfaces = all +inet_protocols = ipv4 + +# Recipient settings +mydestination = localhost.$mydomain, localhost +mynetworks = 127.0.0.0/8 [::1]/128 + +# Relay settings - accept mail for our test domain +relay_domains = $mydomain + +# Queue and size limits +message_size_limit = 10485760 +mailbox_size_limit = 0 +queue_minfree = 50000000 + +# Transport maps - route test emails to happyDeliver analyzer +transport_maps = pcre:/etc/postfix/transport_maps + +# Authentication milters +# OpenDKIM for DKIM verification +milter_default_action = accept +milter_protocol = 6 +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock +non_smtpd_milters = $smtpd_milters + +# SPF policy checking +smtpd_recipient_restrictions = + permit_mynetworks, + reject_unauth_destination + +# Logging +debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf new file mode 100644 index 0000000..9c2ac57 --- /dev/null +++ b/docker/postfix/master.cf @@ -0,0 +1,78 @@ +# Postfix master process configuration for happyDeliver + +# SMTP service +smtp inet n - n - - smtpd + +# Pickup service +pickup unix n - n 60 1 pickup + +# Cleanup service +cleanup unix n - n - 0 cleanup + +# Queue manager +qmgr unix n - n 300 1 qmgr + +# Rewrite service +rewrite unix - - n - - trivial-rewrite + +# Bounce service +bounce unix - - n - 0 bounce + +# Defer service +defer unix - - n - 0 bounce + +# Trace service +trace unix - - n - 0 bounce + +# Verify service +verify unix - - n - 1 verify + +# Flush service +flush unix n - n 1000? 0 flush + +# Proxymap service +proxymap unix - - n - - proxymap + +# Proxywrite service +proxywrite unix - - n - 1 proxymap + +# SMTP client +smtp unix - - n - - smtp + +# Relay service +relay unix - - n - - smtp + +# Showq service +showq unix n - n - - showq + +# Error service +error unix - - n - - error + +# Retry service +retry unix - - n - - error + +# Discard service +discard unix - - n - - discard + +# Local delivery +local unix - n n - - local + +# Virtual delivery +virtual unix - n n - - virtual + +# LMTP delivery +lmtp unix - - n - - lmtp + +# Anvil service +anvil unix - - n - 1 anvil + +# Scache service +scache unix - - n - 1 scache + +# Maildrop service +maildrop unix - n n - - pipe + flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} + +# 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 new file mode 100644 index 0000000..cc1deed --- /dev/null +++ b/docker/postfix/transport_maps @@ -0,0 +1,4 @@ +# Transport map - route test emails to happyDeliver LMTP server +# Pattern: test-@domain.com -> LMTP on localhost: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 new file mode 100644 index 0000000..ce9a31c --- /dev/null +++ b/docker/spamassassin/local.cf @@ -0,0 +1,61 @@ +# SpamAssassin configuration for happyDeliver +# Scores emails for spam characteristics + +# Network tests +# Enable network tests for RBL checks, Razor, Pyzor, etc. +use_network_tests 1 + +# RBL checks +# Enable DNS-based blacklist checks +use_rbls 1 + +# SPF checking +use_spf 1 + +# DKIM checking +use_dkim 1 + +# Bayes filtering +# Disable bayes learning (we're not maintaining a persistent spam database) +use_bayes 0 +bayes_auto_learn 0 + +# Scoring thresholds +# Lower thresholds for testing purposes +required_score 5.0 + +# Report settings +# Add detailed spam report to headers +add_header all Status "_YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_" +add_header all Level _STARS(*)_ +add_header all Report _REPORT_ + +# Rewrite subject line +rewrite_header Subject [SPAM:_SCORE_] + +# Whitelisting and blacklisting +# Accept all mail for analysis (don't reject) +skip_rbl_checks 0 + +# Language settings +# Accept all languages +ok_languages all + +# Network timeout +rbl_timeout 5 + +# User preferences +# 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 new file mode 100644 index 0000000..74f1810 --- /dev/null +++ b/docker/supervisor/supervisord.conf @@ -0,0 +1,87 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/happydeliver/supervisord.log +pidfile=/run/supervisord.pid +loglevel=info + +[unix_http_server] +file=/run/supervisord.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///run/supervisord.sock + +# syslogd service +[program:syslogd] +command=/sbin/syslogd -n +autostart=true +autorestart=true +priority=9 + +# Authentication Milter service +[program:authentication_milter] +command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid +autostart=true +autorestart=true +priority=10 +stdout_logfile=/var/log/happydeliver/authentication_milter.log +stderr_logfile=/var/log/happydeliver/authentication_milter.log +user=mail +group=mail + +# rspamd spam filter +[program:rspamd] +command=/usr/bin/rspamd -f -u rspamd -g mail +autostart=true +autorestart=true +priority=11 +stdout_logfile=/var/log/happydeliver/rspamd.log +stderr_logfile=/var/log/happydeliver/rspamd_error.log +user=root + +# SpamAssassin daemon +[program:spamd] +command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid +autostart=true +autorestart=true +priority=12 +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 +autostart=true +autorestart=true +priority=20 +stdout_logfile=/var/log/happydeliver/postfix.log +stderr_logfile=/var/log/happydeliver/postfix_error.log +user=root + +# happyDeliver API server +[program:happydeliver-api] +command=/usr/local/bin/happyDeliver server -config /etc/happydeliver/config.yaml +autostart=true +autorestart=true +priority=30 +stdout_logfile=/var/log/happydeliver/api.log +stderr_logfile=/var/log/happydeliver/api_error.log +user=happydeliver +environment=GIN_MODE="release" diff --git a/go.mod b/go.mod index 8a2e2d9..d44d5cc 100644 --- a/go.mod +++ b/go.mod @@ -3,58 +3,76 @@ module git.happydns.org/happyDeliver go 1.24.6 require ( - github.com/getkin/kin-openapi v0.132.0 + 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.42.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.1 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // 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.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 github.com/josharian/intern v1.0.0 // indirect 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.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.0 // 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.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.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 033b798..717c4ff 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,21 @@ +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/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -14,39 +24,47 @@ 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= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= 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= @@ -68,6 +86,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/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= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -84,10 +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.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= @@ -98,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= @@ -124,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.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= -github.com/quic-go/quic-go v0.54.0/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= @@ -140,33 +174,38 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +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= @@ -174,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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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= @@ -196,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +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= @@ -223,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= @@ -242,3 +281,9 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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 new file mode 100644 index 0000000..470136e --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,383 @@ +// 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 api + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + + "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, analyzer EmailAnalyzer) *APIHandler { + return &APIHandler{ + storage: store, + config: cfg, + analyzer: analyzer, + startTime: time.Now(), + } +} + +// CreateTest creates a new deliverability test +// (POST /test) +func (h *APIHandler) CreateTest(c *gin.Context) { + // Generate a unique test ID (no database record created) + testID := uuid.New() + + // Convert UUID to base32 string for the API response + base32ID := utils.UUIDToBase32(testID) + + // Generate test email address using Base32-encoded UUID + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + base32ID, + h.config.Email.Domain, + ) + + // Return response + c.JSON(http.StatusCreated, TestResponse{ + Id: base32ID, + Email: openapi_types.Email(email), + Status: TestResponseStatusPending, + 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 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(testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to check test status", + Details: stringPtr(err.Error()), + }) + return + } + + // Determine status based on report existence + var apiStatus TestStatus + if reportExists { + apiStatus = TestStatusAnalyzed + } else { + apiStatus = TestStatusPending + } + + // Generate test email address using Base32-encoded UUID + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + id, + h.config.Email.Domain, + ) + + c.JSON(http.StatusOK, Test{ + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, + }) +} + +// GetReport retrieves the detailed analysis report +// (GET /report/{id}) +func (h *APIHandler) GetReport(c *gin.Context, id 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{ + Error: "not_found", + Message: "Report not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve report", + Details: stringPtr(err.Error()), + }) + return + } + + // Return raw JSON directly + c.Data(http.StatusOK, "application/json", reportJSON) +} + +// GetRawEmail retrieves the raw annotated email +// (GET /report/{id}/raw) +func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, 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{ + Error: "not_found", + Message: "Email not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve raw email", + Details: stringPtr(err.Error()), + }) + return + } + + 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) { + // Calculate uptime + uptime := int(time.Since(h.startTime).Seconds()) + + // Check database connectivity by trying to check if a report exists + dbStatus := StatusComponentsDatabaseUp + if _, err := h.storage.ReportExists(uuid.New()); err != nil { + dbStatus = StatusComponentsDatabaseDown + } + + // Determine overall status + overallStatus := Healthy + if dbStatus == StatusComponentsDatabaseDown { + overallStatus = Unhealthy + } + + mtaStatus := StatusComponentsMtaUp + c.JSON(http.StatusOK, Status{ + Status: overallStatus, + Version: version.Version, + Components: &struct { + Database *StatusComponentsDatabase `json:"database,omitempty"` + Mta *StatusComponentsMta `json:"mta,omitempty"` + }{ + Database: &dbStatus, + Mta: &mtaStatus, + }, + 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/api/helpers.go b/internal/api/helpers.go index b50def0..cce306a 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -21,6 +21,10 @@ package api +func stringPtr(s string) *string { + return &s +} + // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { return &v diff --git a/internal/app/cleanup.go b/internal/app/cleanup.go new file mode 100644 index 0000000..c640df9 --- /dev/null +++ b/internal/app/cleanup.go @@ -0,0 +1,108 @@ +// 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 ( + "context" + "log" + "time" + + "git.happydns.org/happyDeliver/internal/storage" +) + +const ( + // How often to run the cleanup check + cleanupInterval = 1 * time.Hour +) + +// CleanupService handles periodic cleanup of old reports +type CleanupService struct { + store storage.Storage + retention time.Duration + ticker *time.Ticker + done chan struct{} +} + +// NewCleanupService creates a new cleanup service +func NewCleanupService(store storage.Storage, retention time.Duration) *CleanupService { + return &CleanupService{ + store: store, + retention: retention, + done: make(chan struct{}), + } +} + +// Start begins the cleanup service in a background goroutine +func (s *CleanupService) Start(ctx context.Context) { + if s.retention <= 0 { + log.Println("Report retention is disabled (keeping reports forever)") + return + } + + log.Printf("Starting cleanup service: will delete reports older than %s", s.retention) + + // Run cleanup immediately on startup + s.runCleanup() + + // Then run periodically + s.ticker = time.NewTicker(cleanupInterval) + + go func() { + for { + select { + case <-s.ticker.C: + s.runCleanup() + case <-ctx.Done(): + s.Stop() + return + case <-s.done: + return + } + } + }() +} + +// Stop stops the cleanup service +func (s *CleanupService) Stop() { + if s.ticker != nil { + s.ticker.Stop() + } + close(s.done) +} + +// runCleanup performs the actual cleanup operation +func (s *CleanupService) runCleanup() { + cutoffTime := time.Now().Add(-s.retention) + log.Printf("Running cleanup: deleting reports older than %s", cutoffTime.Format(time.RFC3339)) + + deleted, err := s.store.DeleteOldReports(cutoffTime) + if err != nil { + log.Printf("Error during cleanup: %v", err) + return + } + + if deleted > 0 { + log.Printf("Cleanup completed: deleted %d old report(s)", deleted) + } else { + log.Printf("Cleanup completed: no old reports to delete") + } +} diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go new file mode 100644 index 0000000..d8336a5 --- /dev/null +++ b/internal/app/cli_analyzer.go @@ -0,0 +1,634 @@ +// 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" + "flag" + "fmt" + "io" + "log" + "strings" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/pkg/analyzer" +) + +// RunAnalyzer runs the standalone email analyzer (from stdin) +func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error { + // Parse command-line flags + fs := flag.NewFlagSet("analyze", flag.ExitOnError) + jsonOutput := fs.Bool("json", false, "Output results as JSON") + if err := fs.Parse(args); err != nil { + return err + } + + if err := cfg.Validate(); err != nil { + return err + } + + log.Printf("Email analyzer ready, reading from stdin...") + + // Read email from stdin + emailData, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read email from stdin: %w", err) + } + + // Create analyzer with configuration + emailAnalyzer := analyzer.NewEmailAnalyzer(cfg) + + // Analyze the email (using a dummy test ID for standalone mode) + result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New()) + if err != nil { + return fmt.Errorf("failed to analyze email: %w", err) + } + + log.Printf("Analyzing email from: %s", result.Email.From) + + // Output results + if *jsonOutput { + return outputJSON(result, writer) + } + return outputHumanReadable(result, emailAnalyzer, writer) +} + +// outputJSON outputs the report as JSON +func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { + reportJSON, err := json.MarshalIndent(result.Report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + fmt.Fprintln(writer, string(reportJSON)) + return nil +} + +// outputHumanReadable outputs a human-readable summary +func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { + report := result.Report + + // Header with overall score + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade) + fmt.Fprintf(writer, "Test ID: %s\n", report.TestId) + fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST")) + + // Score Summary + if report.Summary != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SCORE BREAKDOWN") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + summary := report.Summary + fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", + summary.DnsScore, summary.DnsGrade) + fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", + summary.AuthenticationScore, summary.AuthenticationGrade) + fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", + summary.BlacklistScore, summary.BlacklistGrade) + fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", + summary.HeaderScore, summary.HeaderGrade) + fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", + summary.SpamScore, summary.SpamGrade) + fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", + summary.ContentScore, summary.ContentGrade) + } + + // DNS Results + if report.DnsResults != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DNS CONFIGURATION") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + dns := report.DnsResults + fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) + if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { + fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) + } + + // MX Records + if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { + fmt.Fprintln(writer, "\n MX Records (From Domain):") + for _, mx := range *dns.FromMxRecords { + status := "✓" + if !mx.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) + if mx.Error != nil { + fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) + } + fmt.Fprintln(writer) + } + } + + // SPF Records + if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { + fmt.Fprintln(writer, "\n SPF Records:") + for _, spf := range *dns.SpfRecords { + status := "✓" + if !spf.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s ", status) + if spf.Domain != nil { + fmt.Fprintf(writer, "Domain: %s", *spf.Domain) + } + if spf.AllQualifier != nil { + fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) + } + fmt.Fprintln(writer) + if spf.Record != nil { + fmt.Fprintf(writer, " %s\n", *spf.Record) + } + if spf.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) + } + } + } + + // DKIM Records + if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { + fmt.Fprintln(writer, "\n DKIM Records:") + for _, dkim := range *dns.DkimRecords { + status := "✓" + if !dkim.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) + if dkim.Record != nil { + fmt.Fprintf(writer, " %s\n", *dkim.Record) + } + if dkim.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) + } + } + } + + // DMARC Record + if dns.DmarcRecord != nil { + fmt.Fprintln(writer, "\n DMARC Record:") + status := "✓" + if !dns.DmarcRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) + if dns.DmarcRecord.Policy != nil { + fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) + } + if dns.DmarcRecord.SubdomainPolicy != nil { + fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) + } + fmt.Fprintln(writer) + if dns.DmarcRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) + } + if dns.DmarcRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) + } + } + + // BIMI Record + if dns.BimiRecord != nil { + fmt.Fprintln(writer, "\n BIMI Record:") + status := "✓" + if !dns.BimiRecord.Valid { + status = "✗" + } + fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", + status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) + if dns.BimiRecord.LogoUrl != nil { + fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) + } + if dns.BimiRecord.VmcUrl != nil { + fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) + } + if dns.BimiRecord.Record != nil { + fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) + } + if dns.BimiRecord.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) + } + } + + // PTR Records + if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { + fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") + for _, ptr := range *dns.PtrRecords { + fmt.Fprintf(writer, " %s\n", ptr) + } + } + + // DNS Errors + if dns.Errors != nil && len(*dns.Errors) > 0 { + fmt.Fprintln(writer, "\n DNS Errors:") + for _, err := range *dns.Errors { + fmt.Fprintf(writer, " ! %s\n", err) + } + } + } + + // Authentication Results + if report.Authentication != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "AUTHENTICATION RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + auth := report.Authentication + + // SPF + if auth.Spf != nil { + fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) + if auth.Spf.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) + } + if auth.Spf.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) + } + fmt.Fprintln(writer) + } + + // DKIM + if auth.Dkim != nil && len(*auth.Dkim) > 0 { + fmt.Fprintln(writer, "\n DKIM:") + for i, dkim := range *auth.Dkim { + fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) + if dkim.Domain != nil { + fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) + if dkim.Selector != nil { + fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) + } + fmt.Fprintf(writer, ")") + } + if dkim.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) + } + fmt.Fprintln(writer) + } + } + + // DMARC + if auth.Dmarc != nil { + fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) + if auth.Dmarc.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) + } + if auth.Dmarc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) + } + fmt.Fprintln(writer) + } + + // ARC + if auth.Arc != nil { + fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) + if auth.Arc.ChainLength != nil { + fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) + } + if auth.Arc.ChainValid != nil { + fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) + } + if auth.Arc.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) + } + fmt.Fprintln(writer) + } + + // BIMI + if auth.Bimi != nil { + fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) + if auth.Bimi.Domain != nil { + fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) + } + if auth.Bimi.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) + } + fmt.Fprintln(writer) + } + + // IP Reverse + if auth.Iprev != nil { + fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) + if auth.Iprev.Ip != nil { + fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) + if auth.Iprev.Hostname != nil { + fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) + } + fmt.Fprintf(writer, ")") + } + if auth.Iprev.Details != nil { + fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) + } + fmt.Fprintln(writer) + } + } + + // Blacklist Results + if report.Blacklists != nil && len(*report.Blacklists) > 0 { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "BLACKLIST CHECKS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + totalChecks := 0 + totalListed := 0 + for ip, checks := range *report.Blacklists { + totalChecks += len(checks) + fmt.Fprintf(writer, "\n IP Address: %s\n", ip) + for _, check := range checks { + status := "✓" + if check.Listed { + status = "✗" + totalListed++ + } + fmt.Fprintf(writer, " %s %s", status, check.Rbl) + if check.Listed { + fmt.Fprintf(writer, " - LISTED") + if check.Response != nil { + fmt.Fprintf(writer, " (%s)", *check.Response) + } + } else { + fmt.Fprintf(writer, " - OK") + } + fmt.Fprintln(writer) + if check.Error != nil { + fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) + } + } + } + fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) + } + + // Header Analysis + if report.HeaderAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "HEADER ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + header := report.HeaderAnalysis + + // Domain Alignment + if header.DomainAlignment != nil { + fmt.Fprintln(writer, "\n Domain Alignment:") + align := header.DomainAlignment + if align.FromDomain != nil { + fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) + if align.FromOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) + } + fmt.Fprintln(writer) + } + if align.ReturnPathDomain != nil { + fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) + if align.ReturnPathOrgDomain != nil { + fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) + } + fmt.Fprintln(writer) + } + if align.Aligned != nil { + fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) + } + if align.RelaxedAligned != nil { + fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) + } + if align.Issues != nil && len(*align.Issues) > 0 { + fmt.Fprintln(writer, " Issues:") + for _, issue := range *align.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + + // Required/Important Headers + if header.Headers != nil { + fmt.Fprintln(writer, "\n Standard Headers:") + importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} + for _, hdrName := range importantHeaders { + if hdr, ok := (*header.Headers)[hdrName]; ok { + status := "✗" + if hdr.Present { + status = "✓" + } + fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) + if hdr.Present { + if hdr.Valid != nil && !*hdr.Valid { + fmt.Fprintf(writer, "INVALID") + } else { + fmt.Fprintf(writer, "OK") + } + if hdr.Importance != nil { + fmt.Fprintf(writer, " [%s]", *hdr.Importance) + } + } else { + fmt.Fprintf(writer, "MISSING") + } + fmt.Fprintln(writer) + if hdr.Issues != nil && len(*hdr.Issues) > 0 { + for _, issue := range *hdr.Issues { + fmt.Fprintf(writer, " - %s\n", issue) + } + } + } + } + } + + // Header Issues + if header.Issues != nil && len(*header.Issues) > 0 { + fmt.Fprintln(writer, "\n Header Issues:") + for _, issue := range *header.Issues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + + // Received Chain + if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { + fmt.Fprintln(writer, "\n Email Path (Received Chain):") + for i, hop := range *header.ReceivedChain { + fmt.Fprintf(writer, " [%d] ", i+1) + if hop.From != nil { + fmt.Fprintf(writer, "%s", *hop.From) + if hop.Ip != nil { + fmt.Fprintf(writer, " (%s)", *hop.Ip) + } + } + if hop.By != nil { + fmt.Fprintf(writer, " -> %s", *hop.By) + } + fmt.Fprintln(writer) + if hop.Timestamp != nil { + fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) + } + } + } + } + + // SpamAssassin Results + if report.Spamassassin != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + sa := report.Spamassassin + fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) + if sa.IsSpam { + fmt.Fprintf(writer, " (SPAM)") + } else { + fmt.Fprintf(writer, " (HAM)") + } + fmt.Fprintln(writer) + + if sa.Version != nil { + fmt.Fprintf(writer, " Version: %s\n", *sa.Version) + } + + if len(sa.TestDetails) > 0 { + fmt.Fprintln(writer, "\n Triggered Tests:") + for _, test := range sa.TestDetails { + scoreStr := "+" + if test.Score < 0 { + scoreStr = "" + } + fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) + if test.Description != nil { + fmt.Fprintf(writer, "\n %s", *test.Description) + } + fmt.Fprintln(writer) + } + } + } + + // Content Analysis + if report.ContentAnalysis != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "CONTENT ANALYSIS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + content := report.ContentAnalysis + + // Basic content info + fmt.Fprintln(writer, "\n Content Structure:") + if content.HasPlaintext != nil { + fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) + } + if content.HasHtml != nil { + fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) + } + if content.TextToImageRatio != nil { + fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) + } + + // Unsubscribe + if content.HasUnsubscribeLink != nil { + fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) + if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { + fmt.Fprintf(writer, " Unsubscribe Methods: ") + for i, method := range *content.UnsubscribeMethods { + if i > 0 { + fmt.Fprintf(writer, ", ") + } + fmt.Fprintf(writer, "%s", method) + } + fmt.Fprintln(writer) + } + } + + // Links + if content.Links != nil && len(*content.Links) > 0 { + fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) + for _, link := range *content.Links { + status := "" + switch link.Status { + case "valid": + status = "✓" + case "broken": + status = "✗" + case "suspicious": + status = "⚠" + case "redirected": + status = "→" + case "timeout": + status = "⏱" + } + fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) + if link.HttpCode != nil { + fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) + } + fmt.Fprintln(writer) + if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { + fmt.Fprintln(writer, " Redirect chain:") + for _, url := range *link.RedirectChain { + fmt.Fprintf(writer, " -> %s\n", url) + } + } + } + } + + // Images + if content.Images != nil && len(*content.Images) > 0 { + fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) + missingAlt := 0 + trackingPixels := 0 + for _, img := range *content.Images { + if !img.HasAlt { + missingAlt++ + } + if img.IsTrackingPixel != nil && *img.IsTrackingPixel { + trackingPixels++ + } + } + fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", + len(*content.Images)-missingAlt, len(*content.Images)) + if trackingPixels > 0 { + fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) + } + } + + // HTML Issues + if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { + fmt.Fprintln(writer, "\n Content Issues:") + for _, issue := range *content.HtmlIssues { + fmt.Fprintf(writer, " [%s] %s: %s\n", + strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) + if issue.Location != nil { + fmt.Fprintf(writer, " Location: %s\n", *issue.Location) + } + if issue.Advice != nil { + fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) + } + } + } + } + + // Footer + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + + return nil +} diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..4b01fbb --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,156 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + fmt.Fprintln(os.Stderr, "Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) + fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) + failed++ + continue + } + + if exists { + fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) + failed++ + continue + } + + restored++ + } + + fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..7149f45 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,117 @@ +// 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 ( + "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" +) + +// RunServer starts the API server and LMTP server +func RunServer(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 err + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // Start cleanup service for old reports + ctx := context.Background() + cleanupSvc := NewCleanupService(store, cfg.ReportRetention) + cleanupSvc.Start(ctx) + defer cleanupSvc.Stop() + + // Start LMTP server in background + go func() { + if err := lmtp.StartServer(cfg.Email.LMTPAddr, store, cfg); err != nil { + log.Fatalf("Failed to start LMTP server: %v", err) + } + }() + + // Create analyzer adapter for API + analyzerAdapter := analyzer.NewAPIAdapter(cfg) + + // Create API handler + handler := api.NewAPIHandler(store, cfg, analyzerAdapter) + + // Set up Gin router + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.Default() + + apiGroup := router.Group("/api") + + if cfg.RateLimit > 0 { + // Set up rate limiting (2x to handle burst) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 2 * time.Second, + Limit: 2 * cfg.RateLimit, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + apiGroup.Use(rateLimiter) + } + + // Register API routes + api.RegisterHandlers(apiGroup, handler) + web.DeclareRoutes(cfg, router) + + // Start API server + log.Printf("Starting API server on %s", cfg.Bind) + log.Printf("Test email domain: %s", cfg.Email.Domain) + + if err := router.Run(cfg.Bind); err != nil { + return err + } + + return nil +} diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..3accc99 --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets") + flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket") + flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres") + flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path") + flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") + flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") + flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") + flag.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 +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..468a2aa --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,189 @@ +// 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 config + +import ( + "flag" + "fmt" + "log" + "net/url" + "os" + "path" + "strings" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Config represents the application configuration +type Config struct { + DevProxy string + Bind string + Database DatabaseConfig + 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 +type DatabaseConfig struct { + Type string + DSN string +} + +// EmailConfig contains email domain and routing settings +type EmailConfig struct { + Domain string + TestAddressPrefix string + LMTPAddr string +} + +// AnalysisConfig contains timeout and behavior settings for email analysis +type AnalysisConfig struct { + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string + DNSWLs []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one +} + +// DefaultConfig returns a configuration with sensible defaults +func DefaultConfig() *Config { + return &Config{ + DevProxy: "", + Bind: ":8080", + ReportRetention: 0, // Keep reports forever by default + RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default) + Database: DatabaseConfig{ + Type: "sqlite", + DSN: "happydeliver.db", + }, + Email: EmailConfig{ + Domain: "happydeliver.local", + TestAddressPrefix: "test-", + LMTPAddr: "127.0.0.1:2525", + }, + Analysis: AnalysisConfig{ + DNSTimeout: 5 * time.Second, + HTTPTimeout: 10 * time.Second, + RBLs: []string{}, + DNSWLs: []string{}, + CheckAllIPs: false, // By default, only check the first IP + }, + } +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = DefaultConfig() + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "happydeliver.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append( + configLocations, + path.Join(home, "happydeliver", "happydeliver.conf"), + path.Join(home, "happydomain", "happydeliver.conf"), + ) + } + + configLocations = append(configLocations, path.Join("etc", "happydeliver.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + 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 { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + return +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Email.Domain == "" { + return fmt.Errorf("email domain cannot be empty") + } + + if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil { + return fmt.Errorf("invalid email domain: %w", err) + } + + if c.Database.Type != "sqlite" && c.Database.Type != "postgres" { + return fmt.Errorf("unsupported database type: %s", c.Database.Type) + } + + if c.Database.DSN == "" { + return fmt.Errorf("database DSN cannot be empty") + } + + return nil +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + if len(value) == 0 { + return + } + + key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..97c8d71 --- /dev/null +++ b/internal/config/custom.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 config + +import ( + "fmt" + "net/url" + "strings" +) + +type StringArray struct { + Array *[]string +} + +func (i *StringArray) String() string { + if i.Array == nil { + return "" + } + + return fmt.Sprintf("%v", *i.Array) +} + +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, strings.Split(value, ",")...) + + 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/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..cd4c344 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,42 @@ +// 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 config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by HAPPYDELIVER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..ec28a58 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +} diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go new file mode 100644 index 0000000..a9b36b9 --- /dev/null +++ b/internal/lmtp/server.go @@ -0,0 +1,148 @@ +// 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 lmtp + +import ( + "fmt" + "io" + "log" + "net" + + "github.com/emersion/go-smtp" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/receiver" + "git.happydns.org/happyDeliver/internal/storage" +) + +// Backend implements smtp.Backend for LMTP server +type Backend struct { + receiver *receiver.EmailReceiver + config *config.Config +} + +// NewBackend creates a new LMTP backend +func NewBackend(store storage.Storage, cfg *config.Config) *Backend { + return &Backend{ + receiver: receiver.NewEmailReceiver(store, cfg), + config: cfg, + } +} + +// NewSession creates a new SMTP/LMTP session +func (b *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) { + return &Session{backend: b}, nil +} + +// Session implements smtp.Session for handling LMTP connections +type Session struct { + backend *Backend + from string + recipients []string +} + +// AuthPlain implements PLAIN authentication (not used for local LMTP) +func (s *Session) AuthPlain(username, password string) error { + // No authentication required for local LMTP + return nil +} + +// Mail is called when MAIL FROM command is received +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { + log.Printf("LMTP: MAIL FROM: %s", from) + s.from = from + return nil +} + +// Rcpt is called when RCPT TO command is received +func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { + log.Printf("LMTP: RCPT TO: %s", to) + s.recipients = append(s.recipients, to) + return nil +} + +// Data is called when DATA command is received and email content is being transferred +func (s *Session) Data(r io.Reader) error { + log.Printf("LMTP: Receiving message data for %d recipient(s)", len(s.recipients)) + + // Read the entire email + emailData, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read email data: %w", err) + } + + 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 { + if err := s.backend.receiver.ProcessEmailBytes(emailData, recipient); err != nil { + log.Printf("LMTP: Failed to process email for %s: %v", recipient, err) + return fmt.Errorf("failed to process email for %s: %w", recipient, err) + } + log.Printf("LMTP: Successfully processed email for %s", recipient) + } + + return nil +} + +// Reset is called when RSET command is received +func (s *Session) Reset() { + log.Printf("LMTP: Session reset") + s.from = "" + s.recipients = nil +} + +// Logout is called when the session is closed +func (s *Session) Logout() error { + log.Printf("LMTP: Session logout") + return nil +} + +// StartServer starts an LMTP server on the specified address +func StartServer(addr string, store storage.Storage, cfg *config.Config) error { + backend := NewBackend(store, cfg) + + server := smtp.NewServer(backend) + server.Addr = addr + server.Domain = cfg.Email.Domain + server.AllowInsecureAuth = true + server.LMTP = true // Enable LMTP mode + + log.Printf("Starting LMTP server on %s", addr) + + // Create TCP listener explicitly + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to create LMTP listener: %w", err) + } + + if err := server.Serve(listener); err != nil { + return fmt.Errorf("LMTP server error: %w", err) + } + + return nil +} diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go new file mode 100644 index 0000000..062a091 --- /dev/null +++ b/internal/receiver/receiver.go @@ -0,0 +1,203 @@ +// 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 receiver + +import ( + "encoding/base32" + "encoding/json" + "fmt" + "io" + "log" + "regexp" + "strings" + + "github.com/google/uuid" + + "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 +type EmailReceiver struct { + storage storage.Storage + config *config.Config + analyzer *analyzer.EmailAnalyzer +} + +// NewEmailReceiver creates a new email receiver +func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver { + return &EmailReceiver{ + storage: store, + config: cfg, + analyzer: analyzer.NewEmailAnalyzer(cfg), + } +} + +// ProcessEmail reads an email from the reader, analyzes it, and stores the results +func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error { + // Read the entire email + rawEmail, err := io.ReadAll(emailData) + if err != nil { + return fmt.Errorf("failed to read email: %w", err) + } + + return r.ProcessEmailBytes(rawEmail, recipientEmail) +} + +// ProcessEmailBytes processes an email from a byte slice +func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error { + + log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail)) + + // Extract test ID from recipient email address + testID, err := r.extractTestID(recipientEmail) + if err != nil { + return fmt.Errorf("failed to extract test ID: %w", err) + } + + log.Printf("Extracted test ID: %s", testID) + + // Check if a report already exists for this test ID + reportExists, err := r.storage.ReportExists(testID) + if err != nil { + return fmt.Errorf("failed to check report existence: %w", err) + } + + if reportExists { + log.Printf("Report already exists for test %s, skipping analysis", testID) + return nil + } + + log.Printf("Analyzing email for test %s", testID) + + // Analyze the email using the shared analyzer + result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return fmt.Errorf("failed to analyze email: %w", err) + } + + 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) + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + + // Store the report + if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { + return fmt.Errorf("failed to store report: %w", err) + } + + log.Printf("Report stored successfully for test %s", testID) + 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 +func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { + // Remove angle brackets if present (e.g., ) + email = strings.Trim(email, "<>") + + // Extract the local part (before @) + parts := strings.Split(email, "@") + if len(parts) != 2 { + return uuid.Nil, fmt.Errorf("invalid email format: %s", email) + } + + localPart := parts[0] + + // Remove the prefix (e.g., "test-") + if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) { + return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email) + } + + uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix) + + // Decode Base32 to UUID + testID, err := base32ToUUID(uuidStr) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err) + } + + return testID, nil +} + +// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers +// This is useful when the email is piped and we need to determine the recipient +func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) { + emailStr := string(rawEmail) + + // Look for common recipient headers + headerPatterns := []string{ + `(?i)^To:\s*(.+)$`, + `(?i)^X-Original-To:\s*(.+)$`, + `(?i)^Delivered-To:\s*(.+)$`, + `(?i)^Envelope-To:\s*(.+)$`, + } + + for _, pattern := range headerPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(emailStr) + if len(matches) > 1 { + recipient := strings.TrimSpace(matches[1]) + // Clean up the email address + recipient = strings.Trim(recipient, "<>") + // Take only the first email if there are multiple + if idx := strings.Index(recipient, ","); idx != -1 { + recipient = recipient[:idx] + } + if recipient != "" { + return recipient, nil + } + } + } + + return "", fmt.Errorf("could not extract recipient from email headers") +} diff --git a/internal/storage/models.go b/internal/storage/models.go new file mode 100644 index 0000000..dbb3daa --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,46 @@ +// 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 storage + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Report represents the analysis report for a test +type Report struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + TestID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` // The test ID extracted from email address + RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers + ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data + CreatedAt time.Time `gorm:"not null"` +} + +// BeforeCreate is a GORM hook that generates a UUID before creating a report +func (r *Report) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..39b2eb6 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,179 @@ +// 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 storage + +import ( + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") +) + +// Storage interface defines operations for persisting and retrieving data +type Storage interface { + // Report operations + 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 + Close() error +} + +// DBStorage implements Storage using GORM +type DBStorage struct { + db *gorm.DB +} + +// NewStorage creates a new storage instance based on database type +func NewStorage(dbType, dsn string) (Storage, error) { + var dialector gorm.Dialector + + switch dbType { + case "sqlite": + dialector = sqlite.Open(dsn) + case "postgres": + dialector = postgres.Open(dsn) + default: + return nil, fmt.Errorf("unsupported database type: %s", dbType) + } + + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Auto-migrate the schema + if err := db.AutoMigrate(&Report{}); err != nil { + return nil, fmt.Errorf("failed to migrate database schema: %w", err) + } + + return &DBStorage{db: db}, nil +} + +// CreateReport creates a new report for a test +func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) { + dbReport := &Report{ + TestID: testID, + RawEmail: rawEmail, + ReportJSON: reportJSON, + } + + if err := s.db.Create(dbReport).Error; err != nil { + return nil, fmt.Errorf("failed to create report: %w", err) + } + + return dbReport, nil +} + +// ReportExists checks if a report exists for the given test ID +func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) { + var count int64 + if err := s.db.Model(&Report{}).Where("test_id = ?", testID).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check report existence: %w", err) + } + return count > 0, nil +} + +// GetReport retrieves a report by test ID, returning the raw JSON and email bytes +func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { + var dbReport Report + if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrNotFound + } + return nil, nil, fmt.Errorf("failed to get report: %w", err) + } + + 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{}) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete old reports: %w", result.Error) + } + return result.RowsAffected, nil +} + +// Close closes the database connection +func (s *DBStorage) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + 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/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go new file mode 100644 index 0000000..a16829b --- /dev/null +++ b/pkg/analyzer/analyzer.go @@ -0,0 +1,148 @@ +// 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 ( + "bytes" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// EmailAnalyzer provides high-level email analysis functionality +// This is the main entry point for analyzing emails from both LMTP and CLI +type EmailAnalyzer struct { + generator *ReportGenerator +} + +// NewEmailAnalyzer creates a new email analyzer with the given configuration +func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { + generator := NewReportGenerator( + cfg.Analysis.DNSTimeout, + cfg.Analysis.HTTPTimeout, + cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, + cfg.Analysis.CheckAllIPs, + ) + + return &EmailAnalyzer{ + generator: generator, + } +} + +// AnalysisResult contains the complete analysis result +type AnalysisResult struct { + Email *EmailMessage + Results *AnalysisResults + Report *api.Report +} + +// AnalyzeEmailBytes performs complete email analysis from raw bytes +func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) { + // Parse the email + emailMsg, err := ParseEmail(bytes.NewReader(rawEmail)) + if err != nil { + return nil, fmt.Errorf("failed to parse email: %w", err) + } + + // Analyze the email + results := a.generator.AnalyzeEmail(emailMsg) + + // Generate the report + report := a.generator.GenerateReport(testID, results) + + return &AnalysisResult{ + Email: emailMsg, + Results: results, + Report: report, + }, nil +} + +// APIAdapter adapts the EmailAnalyzer to work with the API package +// This adapter implements the interface expected by the API handler +type APIAdapter struct { + analyzer *EmailAnalyzer +} + +// NewAPIAdapter creates a new API adapter for the email analyzer +func NewAPIAdapter(cfg *config.Config) *APIAdapter { + return &APIAdapter{ + analyzer: NewEmailAnalyzer(cfg), + } +} + +// AnalyzeEmailBytes performs analysis and returns JSON bytes directly +func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { + result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return nil, err + } + + // Marshal report to JSON + reportJSON, err := json.Marshal(result.Report) + if err != nil { + return nil, fmt.Errorf("failed to marshal report: %w", err) + } + + return reportJSON, nil +} + +// AnalyzeDomain performs DNS analysis for a domain and returns the results +func (a *APIAdapter) AnalyzeDomain(domain string) (*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/renovate.json b/renovate.json new file mode 100644 index 0000000..958a423 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>iac/renovate-config", + "local>iac/renovate-config//automerge-common" + ] +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..d7033d5 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# OpenAPI +src/lib/api \ No newline at end of file diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..f9333ff --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,13 @@ +{ + "tabWidth": 4, + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..9b6ace7 --- /dev/null +++ b/web/assets.go @@ -0,0 +1,43 @@ +// 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 web + +import ( + "embed" + "io/fs" + "log" + "net/http" +) + +//go:embed all:build + +var _assets embed.FS + +var Assets http.FileSystem + +func init() { + sub, err := fs.Sub(_assets, "build") + if err != nil { + log.Fatal("Unable to cd to build/ directory:", err) + } + Assets = http.FS(sub) +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..a477855 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,41 @@ +import prettier from "eslint-config-prettier"; +import { fileURLToPath } from "node:url"; +import { includeIgnoreFile } from "@eslint/compat"; +import js from "@eslint/js"; +import svelte from "eslint-plugin-svelte"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import ts from "typescript-eslint"; +import svelteConfig from "./svelte.config.js"; + +const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + "no-undef": "off", + }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: [".svelte"], + parser: ts.parser, + svelteConfig, + }, + }, + }, +); diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts new file mode 100644 index 0000000..dfe34de --- /dev/null +++ b/web/openapi-ts.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + input: "../api/openapi.yaml", + output: "src/lib/api", + plugins: [ + { + name: "@hey-api/client-fetch", + runtimeConfigPath: "$lib/hey-api.ts", + }, + ], +}); diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..835218b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4384 @@ +{ + "name": "happyDeliver", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "happyDeliver", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1" + }, + "devDependencies": { + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.36.0", + "@hey-api/openapi-ts": "0.86.10", + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/node": "^24.0.0", + "eslint": "^9.38.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^17.0.0", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.10", + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "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": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "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.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "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": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "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": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "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.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.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": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz", + "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.86.10", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.10.tgz", + "integrity": "sha512-Ns0dTJp/RUrOMPiJsO4/1E2Sa3VZ1iw2KCdG6PDbd9vLwOXEYW2UmiWMDPOTInLCYB+f8FLMF9T25jtfQe7AZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "^0.3.2", + "@hey-api/json-schema-ref-parser": "1.2.1", + "ansi-colors": "4.1.3", + "c12": "3.3.1", + "color-support": "1.1.3", + "commander": "14.0.1", + "handlebars": "4.7.8", + "open": "10.2.0", + "semver": "7.7.2" + }, + "bin": { + "openapi-ts": "bin/run.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "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.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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "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": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "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, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "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.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", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "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": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "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": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "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.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.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.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.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "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.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "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": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.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": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/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.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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.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.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "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.3", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.0.0", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.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": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "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": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "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": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/devalue": { + "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" + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "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.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@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", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "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": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/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", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "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": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "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": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "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.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "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", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "2.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" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "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": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "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": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@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" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "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" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.49.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz", + "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "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.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "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": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "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": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "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": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "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.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "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", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e5b88f2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,43 @@ +{ + "name": "happyDeliver", + "version": "0.1.0", + "type": "module", + "license": "AGPL-3.0-or-later", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "test": "vitest", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "generate:api": "openapi-ts" + }, + "devDependencies": { + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.36.0", + "@hey-api/openapi-ts": "0.86.10", + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/node": "^24.0.0", + "eslint": "^9.38.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^17.0.0", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.10", + "vitest": "^3.2.4" + }, + "dependencies": { + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1" + } +} diff --git a/web/routes.go b/web/routes.go new file mode 100644 index 0000000..876954c --- /dev/null +++ b/web/routes.go @@ -0,0 +1,204 @@ +// 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 web + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "net/url" + "os" + "path" + "strings" + "text/template" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/config" +) + +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 { + CustomHeadHTML += `` + } + + if cfg.DevProxy != "" { + router.GET("/.svelte-kit/*_", serveOrReverse("", cfg)) + router.GET("/node_modules/*_", serveOrReverse("", cfg)) + router.GET("/@vite/*_", serveOrReverse("", cfg)) + router.GET("/@id/*_", serveOrReverse("", cfg)) + router.GET("/@fs/*_", serveOrReverse("", cfg)) + router.GET("/src/*_", serveOrReverse("", cfg)) + router.GET("/home/*_", serveOrReverse("", cfg)) + } + router.GET("/_app/", serveOrReverse("", cfg)) + 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)) + + router.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") { + c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"}) + } else { + serveOrReverse("/", cfg)(c) + } + }) +} + +func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { + if cfg.DevProxy != "" { + // Forward to the Svelte dev proxy + return func(c *gin.Context) { + if u, err := url.Parse(cfg.DevProxy); err != nil { + http.Error(c.Writer, err.Error(), http.StatusInternalServerError) + } else { + if forced_url != "" && forced_url != "/" { + u.Path = path.Join(u.Path, forced_url) + } else { + u.Path = path.Join(u.Path, c.Request.URL.Path) + } + + u.RawQuery = c.Request.URL.RawQuery + + if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil { + http.Error(c.Writer, err.Error(), http.StatusInternalServerError) + } else if resp, err := http.DefaultClient.Do(r); err != nil { + http.Error(c.Writer, err.Error(), http.StatusBadGateway) + } else { + defer resp.Body.Close() + + if u.Path != "/" || resp.StatusCode != 200 { + for key := range resp.Header { + c.Writer.Header().Add(key, resp.Header.Get(key)) + } + c.Writer.WriteHeader(resp.StatusCode) + + io.Copy(c.Writer, resp.Body) + } else { + for key := range resp.Header { + if strings.ToLower(key) != "content-length" { + c.Writer.Header().Add(key, resp.Header.Get(key)) + } + } + + v, _ := io.ReadAll(resp.Body) + + v2 := strings.Replace(strings.Replace(string(v), "", `{{ .Head }}`, 1), "", "{{ .Body }}", 1) + + indexTpl = template.Must(template.New("index.html").Parse(v2)) + + if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), + }); err != nil { + log.Println("Unable to return index.html:", err.Error()) + } + } + } + } + } + } else if Assets == nil { + return func(c *gin.Context) { + c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web") + } + } else if forced_url == "/" { + // Serve altered index.html + return func(c *gin.Context) { + if indexTpl == nil { + // Create template from file + f, _ := Assets.Open("index.html") + v, _ := io.ReadAll(f) + + 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{ + "Body": CustomBodyHTML, + "Head": CustomHeadHTML, + "RootURL": fmt.Sprintf("https://%s/", c.Request.Host), + }); err != nil { + log.Println("Unable to return index.html:", err.Error()) + } + } + } else if forced_url != "" { + // Serve forced_url + return func(c *gin.Context) { + c.FileFromFS(forced_url, Assets) + } + } else { + // Serve requested file + return func(c *gin.Context) { + if _, err := fs.Stat(_assets, path.Join("build", c.Request.URL.Path)); os.IsNotExist(err) { + c.FileFromFS("/404.html", Assets) + } else { + c.FileFromFS(c.Request.URL.Path, Assets) + } + } + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..dca80a5 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,166 @@ +: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 { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.bg-tertiary { + background-color: var(--bs-tertiary-bg); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.fade-in { + animation: fadeIn 0.6s ease-out; +} + +.pulse { + animation: pulse 2s ease-in-out infinite; +} + +.spin { + animation: spin 1s linear infinite; +} + +/* Score styling */ +.score-excellent { + color: #198754; +} + +.score-good { + color: #20c997; +} + +.score-warning { + color: #ffc107; +} + +.score-poor { + color: #fd7e14; +} + +.score-bad { + color: #dc3545; +} + +/* Custom card styling */ +.card { + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.card:not(.fade-in .card) { + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.fade-in .card:not(.card .card) { + border: none; +} + +.card:hover:not(.fade-in .card) { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +/* Check status badges */ +.check-status { + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; +} + +.check-pass { + background-color: #d1e7dd; + color: #0f5132; +} + +.check-fail { + background-color: #f8d7da; + color: #842029; +} + +.check-warn { + background-color: #fff3cd; + color: #664d03; +} + +.check-info { + background-color: #cfe2ff; + color: #084298; +} + +/* Clipboard button */ +.clipboard-btn { + cursor: pointer; + transition: all 0.2s ease; +} + +.clipboard-btn:hover { + transform: scale(1.1); +} + +.clipboard-btn:active { + transform: scale(0.95); +} + +/* Progress bar animation */ +.progress-bar { + transition: width 0.6s ease; +} + +/* Hero section */ +.hero { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +/* Feature icons */ +.feature-icon { + width: 4rem; + height: 4rem; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + margin-bottom: 1rem; +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..d76242a --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..9e3bf88 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + %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/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 new file mode 100644 index 0000000..5b5f051 --- /dev/null +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -0,0 +1,68 @@ + + +
+
+ + +
+ {#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/FeatureCard.svelte b/web/src/lib/components/FeatureCard.svelte new file mode 100644 index 0000000..87baea4 --- /dev/null +++ b/web/src/lib/components/FeatureCard.svelte @@ -0,0 +1,33 @@ + + +
+
+ +
+
{title}
+

+ {description} +

+
+ + 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/HowItWorksStep.svelte b/web/src/lib/components/HowItWorksStep.svelte new file mode 100644 index 0000000..87d8544 --- /dev/null +++ b/web/src/lib/components/HowItWorksStep.svelte @@ -0,0 +1,17 @@ + + +
+
{step}
+
{title}
+

+ {description} +

+
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 new file mode 100644 index 0000000..afbc426 --- /dev/null +++ b/web/src/lib/components/PendingState.svelte @@ -0,0 +1,145 @@ + + +
+
+
+
+
+ +
+ +

Waiting for Your Email

+

Send your test email to the address below:

+ +
+ +
+ + + +
+
+ {#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} +
+
+
+ + +
+
+
+ What we'll check: +
+
+
+
    +
  • + SPF, DKIM, DMARC, BIMI +
  • +
  • + DNS Records +
  • +
  • + SpamAssassin Score +
  • +
+
+
+
    +
  • + Blacklist Status +
  • +
  • + Content Quality +
  • +
  • + Header Validation +
  • +
+
+
+
+
+
+
+ + 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 new file mode 100644 index 0000000..7a80dc4 --- /dev/null +++ b/web/src/lib/components/ScoreCard.svelte @@ -0,0 +1,162 @@ + + +
+
+
+ {#if reanalyzing} +
+ {:else} + + {/if} +
+

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

+

Overall Deliverability Score

+ + {#if summary} + + {/if} +
+
+ + diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte new file mode 100644 index 0000000..cc88c23 --- /dev/null +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -0,0 +1,138 @@ + + +
+
+

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

+
+
+
+
+ Score: + + {spamassassin.score.toFixed(2)} / {spamassassin.required_score.toFixed(1)} + +
+
+ Classified as: + + {spamassassin.is_spam ? "SPAM" : "HAM"} + +
+
+ + {#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} + {/each} +
+
+ {/if} + + {#if spamassassin.report} +
+ Raw Report +
{spamassassin.report}
+
+ {/if} +
+
+ + 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 new file mode 100644 index 0000000..8ed409c --- /dev/null +++ b/web/src/lib/components/index.ts @@ -0,0 +1,27 @@ +// Component exports +export { default as AuthenticationCard } from "./AuthenticationCard.svelte"; +export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte"; +export { default as BlacklistCard } from "./BlacklistCard.svelte"; +export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; +export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; +export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; +export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; +export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; +export { default as EmailPathCard } from "./EmailPathCard.svelte"; +export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; +export { default as FeatureCard } from "./FeatureCard.svelte"; +export { default as GradeDisplay } from "./GradeDisplay.svelte"; +export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; +export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; +export { default as Logo } from "./Logo.svelte"; +export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte"; +export { default as PendingState } from "./PendingState.svelte"; +export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; +export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; +export { default as ScoreCard } from "./ScoreCard.svelte"; +export { default as RspamdCard } from "./RspamdCard.svelte"; +export { default as 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 new file mode 100644 index 0000000..6983e5d --- /dev/null +++ b/web/src/lib/hey-api.ts @@ -0,0 +1,30 @@ +import type { CreateClientConfig } from "./api/client.gen"; + +export class NotAuthorizedError extends Error { + constructor(message: string) { + super(message); + this.name = "NotAuthorizedError"; + } +} + +async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); + + if (response.status === 400) { + const json = await response.json(); + if ( + json.error == + "error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session" + ) { + throw new NotAuthorizedError(json.error.substring(80)); + } + } + + return response; +} + +export const createClientConfig: CreateClientConfig = (config) => ({ + ...config, + baseUrl: "/api/", + fetch: customFetch, +}); diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. 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 new file mode 100644 index 0000000..0e103e5 --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,32 @@ + + + + {status} - {getErrorTitle(status)} | happyDeliver + + +
+ +
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..077f340 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,209 @@ + + + + + + +
+ + +
+ {@render children?.()} +
+ + + + +
+ + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..7c23d10 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,345 @@ + + + + happyDeliver. Test Your Email Deliverability. + + + +
+
+
+
+

Test Your Email Deliverability

+

+ Get detailed insights into your email configuration, authentication, spam score, + and more. Open-source, self-hosted, and privacy-focused. +

+ + + {#if error} + + {/if} +
+
+
+
+ + +
+
+
+
+

Comprehensive Email Analysis

+

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

+
+
+ +
+ {#each features as feature} +
+ +
+ {/each} +
+
+
+ + +
+
+
+
+

How It Works

+

+ Simple three-step process to test your email deliverability +

+
+
+ +
+ {#each steps as stepData} +
+ +
+ {/each} +
+ +
+ +
+ + +
+
+ + 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 new file mode 100644 index 0000000..113209d --- /dev/null +++ b/web/src/routes/test/[test]/+page.svelte @@ -0,0 +1,496 @@ + + + + + {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 + + + +
+ {#if loading} +
+
+ Loading... +
+

Loading test...

+
+ {:else if error} + + {:else if test && test.status !== "analyzed"} + + fetchTest()} + /> + {:else if report} + +
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
+ +
+ +
+
+
+
+ + + {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} +
+
+ +
+
+ {/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} + + + +
+ {/if} +
+ + 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 diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..85baa28 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,19 @@ +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + fallback: "index.html", + }), + paths: { + relative: process.env.MODE === "production", + }, + }, +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c63ecc0 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..f4fb896 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; +import { sveltekit } from "@sveltejs/kit/vite"; + +export default defineConfig({ + server: { + hmr: { + port: 10000, + }, + }, + plugins: [sveltekit()], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: "./vite.config.ts", + test: { + name: "server", + environment: "node", + include: ["src/**/*.{test,spec}.{js,ts}"], + exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], + }, + }, + ], + }, +});