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..053beb0 --- /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:22-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 main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver + - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver + environment: + CGO_ENABLED: 0 + 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 main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver + environment: + CGO_ENABLED: 0 + when: + event: + - tag + +- name: build-commit macOS + image: golang:1-alpine + commands: + - apk add --no-cache git + - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + 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 "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + environment: + CGO_ENABLED: 0 + GOOS: darwin + 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..36d7d33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# Multi-stage Dockerfile for happyDeliver with integrated MTA +# Stage 1: Build the Svelte application +FROM node:22-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: Runtime image with Postfix and all filters +FROM alpine:3 + +# Install all required packages +RUN apk add --no-cache \ + bash \ + ca-certificates \ + opendkim \ + opendkim-utils \ + opendmarc \ + postfix \ + postfix-pcre \ + postfix-policyd-spf-perl \ + spamassassin \ + spamassassin-client \ + supervisor \ + sqlite \ + tzdata \ + && rm -rf /var/cache/apk/* + +# Get test-only version of postfix-policyd-spf-perl +ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl +RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl + +# 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/spool/postfix/opendkim \ + /var/spool/postfix/opendmarc \ + /etc/opendkim/keys \ + && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ + && chown -R opendkim:postfix /var/spool/postfix/opendkim \ + && chown -R opendmarc:postfix /var/spool/postfix/opendmarc + +# 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/opendkim/ /etc/opendkim/ +COPY docker/opendmarc/ /etc/opendmarc/ +COPY docker/spamassassin/ /etc/mail/spamassassin/ +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 HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net + +# Volume for persistent data +VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] + +# 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..b9db23c --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# 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, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **REST API**: Full-featured API for creating tests and retrieving reports +- **LMTP Server**: Built-in LMTP server for seamless MTA integration +- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers +- **Database Storage**: SQLite or PostgreSQL support +- **Configurable**: via environment or config file for all settings + +## Quick Start + +### With Docker (Recommended) + +The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application. + +#### What's included in the Docker container: + +- **Postfix MTA**: Receives emails on port 25 +- **OpenDKIM**: DKIM signature verification +- **OpenDMARC**: DMARC policy validation +- **SpamAssassin**: Spam scoring and analysis +- **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 and HOSTNAME environment variables + +# 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 \ + -e HOSTNAME=mail.yourdomain.com \ + -v $(pwd)/data:/var/lib/happydeliver \ + -v $(pwd)/logs:/var/log/happydeliver \ + happydeliver:latest +``` + +### 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, ... +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-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 + ``` + +3. Append the created file to `transport_maps` in your `main.cf`: + + ```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-550e8400@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 0 to 10 based on: + +- **Authentication (3 pts)**: SPF, DKIM, DMARC validation +- **Spam (2 pts)**: SpamAssassin score +- **Blacklist (2 pts)**: RBL/DNSBL checks +- **Content (2 pts)**: HTML quality, links, images, unsubscribe +- **Headers (1 pt)**: Required headers, MIME structure + +**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor. + +**Ratings:** +- 9-10: Excellent +- 7-8.9: Good +- 5-6.9: Fair +- 3-4.9: Poor +- 0-2.9: Critical + +## 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..83151de 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. Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -63,13 +63,13 @@ paths: format: uuid 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: @@ -168,8 +168,8 @@ components: example: "test-550e8400@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 @@ -353,6 +353,10 @@ components: $ref: '#/components/schemas/AuthResult' dmarc: $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' AuthResult: type: object @@ -376,6 +380,29 @@ 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" + SpamAssassinResult: type: object required: @@ -420,7 +447,7 @@ components: example: "example.com" record_type: type: string - enum: [MX, SPF, DKIM, DMARC] + enum: [MX, SPF, DKIM, DMARC, BIMI] description: DNS record type example: "SPF" status: 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 63445d1..01d99f1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -22,14 +22,20 @@ package main import ( + "flag" "fmt" "log" "os" + + "git.happydns.org/happyDeliver/internal/app" + "git.happydns.org/happyDeliver/internal/config" ) +const version = "0.1.0-dev" + func main() { - fmt.Println("Mail Tester - Email Deliverability Testing Platform") - fmt.Println("Version: 0.1.0-dev") + fmt.Println("happyDeliver - Email Deliverability Testing Platform") + fmt.Printf("Version: %s\n", version) cfg, err := config.ConsolidateConfig() if err != nil { @@ -40,13 +46,15 @@ func main() { 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 "version": - fmt.Println("0.1.0-dev") + fmt.Println(version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -56,9 +64,9 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87521ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + happydeliver: + build: + context: . + dockerfile: Dockerfile + image: happydeliver:latest + container_name: happydeliver + hostname: mail.happydeliver.local + + environment: + # Set your domain and hostname + DOMAIN: happydeliver.local + HOSTNAME: mail.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 + + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + data: + logs: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..45cce6b --- /dev/null +++ b/docker/README.md @@ -0,0 +1,164 @@ +# 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: + +- `DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HOSTNAME`: Container hostname (default: mail.happydeliver.local) + +Example: +```bash +docker run -e DOMAIN=example.com -e 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/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..445602d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -e + +echo "Starting happyDeliver container..." + +# Get environment variables with defaults +HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" +HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" + +echo "Hostname: $HOSTNAME" +echo "Domain: $HAPPYDELIVER_DOMAIN" + +# Create runtime directories +mkdir -p /var/run/opendkim /var/run/opendmarc +chown opendkim:postfix /var/run/opendkim +chown opendmarc:postfix /var/run/opendmarc + +# Create socket directories +mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc +chown opendkim:postfix /var/spool/postfix/opendkim +chown opendmarc:postfix /var/spool/postfix/opendmarc +chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc + +# Create log directory +mkdir -p /var/log/happydeliver +chown happydeliver:happydeliver /var/log/happydeliver + +# Replace placeholders in Postfix configuration +echo "Configuring Postfix..." +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf +sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf + +# Replace placeholders in OpenDMARC configuration +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf + +# 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/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf new file mode 100644 index 0000000..8fe2f8c --- /dev/null +++ b/docker/opendkim/opendkim.conf @@ -0,0 +1,39 @@ +# OpenDKIM configuration for happyDeliver +# Verifies DKIM signatures on incoming emails + +# Log to syslog +Syslog yes +SyslogSuccess yes +LogWhy yes + +# Run as this user and group +UserID opendkim:mail + +UMask 002 + +# Socket for Postfix communication +Socket unix:/var/spool/postfix/opendkim/opendkim.sock + +# Process ID file +PidFile /var/run/opendkim/opendkim.pid + +# Operating mode - verify only (not signing) +Mode v + +# Canonicalization methods +Canonicalization relaxed/simple + +# DNS timeout +DNSTimeout 5 + +# Add header for verification results +AlwaysAddARHeader yes + +# Accept unsigned mail +On-NoSignature accept + +# Always add Authentication-Results header +AlwaysAddARHeader yes + +# Maximum verification attempts +MaximumSignaturesToVerify 3 diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf new file mode 100644 index 0000000..882e11c --- /dev/null +++ b/docker/opendmarc/opendmarc.conf @@ -0,0 +1,41 @@ +# OpenDMARC configuration for happyDeliver +# Verifies DMARC policies on incoming emails + +# Socket for Postfix communication +Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock + +# Process ID file +PidFile /var/run/opendmarc/opendmarc.pid + +# Run as this user and group +UserID opendmarc:mail + +UMask 002 + +# Syslog configuration +Syslog true +SyslogFacility mail + +# Ignore authentication results from other hosts +IgnoreAuthenticatedClients true + +# Accept mail even if DMARC fails (we're analyzing, not filtering) +RejectFailures false + +# Trust Authentication-Results headers from localhost only +TrustedAuthservIDs __HOSTNAME__ + +# Add DMARC results to Authentication-Results header +#AddAuthenticationResults true + +# DNS timeout +DNSTimeout 5 + +# History file (for reporting) +# HistoryFile /var/spool/opendmarc/opendmarc.dat + +# Ignore hosts file +# IgnoreHosts /etc/opendmarc/ignore.hosts + +# Public suffix list +# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat diff --git a/docker/postfix/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..913eb57 --- /dev/null +++ b/docker/postfix/main.cf @@ -0,0 +1,41 @@ +# 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 = $myhostname, 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/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock +non_smtpd_milters = $smtpd_milters + +# SPF policy checking +smtpd_recipient_restrictions = + permit_mynetworks, + reject_unauth_destination, + check_policy_service unix:private/policy-spf + +# Logging +debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf new file mode 100644 index 0000000..92976a4 --- /dev/null +++ b/docker/postfix/master.cf @@ -0,0 +1,83 @@ +# Postfix master process configuration for happyDeliver + +# SMTP service +smtp inet n - n - - smtpd + -o content_filter=spamassassin + +# 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} + +# SPF policy service +policy-spf unix - n n - 0 spawn + user=nobody argv=/usr/bin/postfix-policyd-spf-perl + +# SpamAssassin content filter +spamassassin unix - n n - - pipe + user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps new file mode 100644 index 0000000..49fdb98 --- /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-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf new file mode 100644 index 0000000..c248ef6 --- /dev/null +++ b/docker/spamassassin/local.cf @@ -0,0 +1,50 @@ +# 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 diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf new file mode 100644 index 0000000..1a0666e --- /dev/null +++ b/docker/supervisor/supervisord.conf @@ -0,0 +1,76 @@ +[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 + +# OpenDKIM service +[program:opendkim] +command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf +autostart=true +autorestart=true +priority=10 +stdout_logfile=/var/log/happydeliver/opendkim.log +stderr_logfile=/var/log/happydeliver/opendkim_error.log +user=opendkim +group=mail + +# OpenDMARC service +[program:opendmarc] +command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf +autostart=true +autorestart=true +priority=11 +stdout_logfile=/var/log/happydeliver/opendmarc.log +stderr_logfile=/var/log/happydeliver/opendmarc_error.log +user=opendmarc +group=mail + +# 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 + +# 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..7604b07 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,15 @@ module git.happydns.org/happyDeliver go 1.24.6 require ( + github.com/emersion/go-smtp v0.24.0 github.com/getkin/kin-openapi v0.132.0 github.com/gin-gonic/gin v1.11.0 github.com/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.46.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.0 ) require ( @@ -16,6 +20,7 @@ require ( github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -25,12 +30,19 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect 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/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -40,7 +52,7 @@ require ( 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/quic-go v0.54.1 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -48,12 +60,12 @@ require ( 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 + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 033b798..bc46bc0 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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= @@ -68,6 +72,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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +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= @@ -88,6 +104,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -126,8 +144,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= +github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -143,6 +161,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE 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= @@ -162,11 +181,11 @@ golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -174,13 +193,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -196,21 +215,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -242,3 +261,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.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..79d839e --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,194 @@ +// 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" +) + +// APIHandler implements the ServerInterface for handling API requests +type APIHandler struct { + storage storage.Storage + config *config.Config + startTime time.Time +} + +// NewAPIHandler creates a new API handler +func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { + return &APIHandler{ + storage: store, + config: cfg, + 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() + + // Generate test email address + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + testID.String(), + h.config.Email.Domain, + ) + + // Return response + c.JSON(http.StatusCreated, TestResponse{ + Id: testID, + Email: openapi_types.Email(email), + Status: TestResponseStatusPending, + Message: stringPtr("Send your test email to the address above"), + }) +} + +// GetTest retrieves test metadata +// (GET /test/{id}) +func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { + // Check if a report exists for this test ID + reportExists, err := h.storage.ReportExists(id) + 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 + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + id.String(), + h.config.Email.Domain, + ) + + // Return current time for CreatedAt/UpdatedAt since we don't track tests anymore + now := time.Now() + + c.JSON(http.StatusOK, Test{ + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, + CreatedAt: now, + UpdatedAt: &now, + }) +} + +// GetReport retrieves the detailed analysis report +// (GET /report/{id}) +func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { + reportJSON, _, err := h.storage.GetReport(id) + 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 openapi_types.UUID) { + _, rawEmail, err := h.storage.GetReport(id) + 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) +} + +// 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: "0.1.0-dev", + Components: &struct { + Database *StatusComponentsDatabase `json:"database,omitempty"` + Mta *StatusComponentsMta `json:"mta,omitempty"` + }{ + Database: &dbStatus, + Mta: &mtaStatus, + }, + Uptime: &uptime, + }) +} 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..2cccf1b --- /dev/null +++ b/internal/app/cli_analyzer.go @@ -0,0 +1,143 @@ +// 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/api" + "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 { + // Header + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + + // Score summary + summary := emailAnalyzer.GetScoreSummaryText(result) + fmt.Fprintln(writer, summary) + + // Detailed checks + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DETAILED CHECK RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + // Group checks by category + categories := make(map[api.CheckCategory][]api.Check) + for _, check := range result.Report.Checks { + categories[check.Category] = append(categories[check.Category], check) + } + + // Print checks by category + categoryOrder := []api.CheckCategory{ + api.Authentication, + api.Dns, + api.Blacklist, + api.Content, + api.Headers, + } + + for _, category := range categoryOrder { + checks, ok := categories[category] + if !ok || len(checks) == 0 { + continue + } + + fmt.Fprintf(writer, "\n%s:\n", category) + for _, check := range checks { + statusSymbol := "✓" + if check.Status == api.CheckStatusFail { + statusSymbol = "✗" + } else if check.Status == api.CheckStatusWarn { + statusSymbol = "⚠" + } + + fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message) + if check.Advice != nil && *check.Advice != "" { + fmt.Fprintf(writer, " → %s\n", *check.Advice) + } + } + } + + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..332516b --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,89 @@ +// 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" + + "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/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 API handler + handler := api.NewAPIHandler(store, cfg) + + // Set up Gin router + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.Default() + + // Register API routes + apiGroup := router.Group("/api") + 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 index eee2c4c..93c18ce 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -33,9 +33,11 @@ func declareFlags(o *Config) { 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.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index a866fbd..510aaa9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,11 +35,12 @@ import ( // Config represents the application configuration type Config struct { - DevProxy string - Bind string - Database DatabaseConfig - Email EmailConfig - Analysis AnalysisConfig + DevProxy string + Bind string + Database DatabaseConfig + Email EmailConfig + Analysis AnalysisConfig + ReportRetention time.Duration // How long to keep reports. 0 = keep forever } // DatabaseConfig contains database connection settings @@ -52,6 +53,7 @@ type DatabaseConfig struct { type EmailConfig struct { Domain string TestAddressPrefix string + LMTPAddr string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -64,8 +66,9 @@ type AnalysisConfig struct { // DefaultConfig returns a configuration with sensible defaults func DefaultConfig() *Config { return &Config{ - DevProxy: "", - Bind: ":8080", + DevProxy: "", + Bind: ":8080", + ReportRetention: 0, // Keep reports forever by default Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", @@ -73,6 +76,7 @@ func DefaultConfig() *Config { Email: EmailConfig{ Domain: "happydeliver.local", TestAddressPrefix: "test-", + LMTPAddr: "127.0.0.1:2525", }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go new file mode 100644 index 0000000..1d9a720 --- /dev/null +++ b/internal/lmtp/server.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 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)) + + // 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..1132b54 --- /dev/null +++ b/internal/receiver/receiver.go @@ -0,0 +1,176 @@ +// 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/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. Score: %.2f/10", 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 +} + +// 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) + + // Parse UUID + testID, err := uuid.Parse(uuidStr) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr) + } + + 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..7c27279 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,136 @@ +// 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) + 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.First(&dbReport, "test_id = ?", testID).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 +} + +// 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() +} diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go new file mode 100644 index 0000000..3588280 --- /dev/null +++ b/pkg/analyzer/analyzer.go @@ -0,0 +1,87 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "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, + ) + + 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 +} + +// GetScoreSummaryText returns a human-readable score summary +func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { + if result == nil || result.Results == nil { + return "" + } + return a.generator.GetScoreSummaryText(result.Results) +} diff --git a/internal/analyzer/authentication.go b/pkg/analyzer/authentication.go similarity index 57% rename from internal/analyzer/authentication.go rename to pkg/analyzer/authentication.go index 45df0a3..d6fd600 100644 --- a/internal/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -59,6 +59,14 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api } } + // 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 } @@ -104,6 +112,20 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, 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) + } + } } } @@ -214,6 +236,201 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { return result } +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.ARCResult{} + + // Extract result (pass, fail, none) + re := regexp.MustCompile(`arc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.ARCResultResult(resultStr) + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// parseARCHeaders parses ARC headers from email message +// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &api.ARCResult{ + Result: api.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = api.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = api.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = api.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} + +// contains checks if a slice contains an integer +func contains(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + // parseLegacySPF attempts to parse SPF from Received-SPF header func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") @@ -288,224 +505,3 @@ func textprotoCanonical(s string) string { } 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/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go new file mode 100644 index 0000000..01298a0 --- /dev/null +++ b/pkg/analyzer/authentication_checks.go @@ -0,0 +1,304 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// GenerateAuthenticationChecks generates check results for authentication +func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { + var checks []api.Check + + // SPF check + if results.Spf != nil { + check := a.generateSPFCheck(results.Spf) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "SPF Record", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No SPF authentication result found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), + }) + } + + // DKIM check + if results.Dkim != nil && len(*results.Dkim) > 0 { + for i, dkim := range *results.Dkim { + check := a.generateDKIMCheck(&dkim, i) + checks = append(checks, check) + } + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DKIM Signature", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DKIM signature found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), + }) + } + + // DMARC check + if results.Dmarc != nil { + check := a.generateDMARCCheck(results.Dmarc) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DMARC authentication result found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Implement DMARC policy for your domain"), + }) + } + + // BIMI check (optional, informational only) + if results.Bimi != nil { + check := a.generateBIMICheck(results.Bimi) + checks = append(checks, check) + } + + // ARC check (optional, for forwarded emails) + if results.Arc != nil { + check := a.generateARCCheck(results.Arc) + checks = append(checks, check) + } + + return checks +} + +func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "SPF Record", + } + + switch spf.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "SPF validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your SPF record is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "SPF validation failed" + check.Severity = api.PtrTo(api.CheckSeverityCritical) + check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") + case api.AuthResultResultSoftfail: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation softfail" + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Review your SPF record configuration") + case api.AuthResultResultNeutral: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation neutral" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("Consider tightening your SPF policy") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Review your SPF record configuration") + } + + if spf.Domain != nil { + details := fmt.Sprintf("Domain: %s", *spf.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: fmt.Sprintf("DKIM Signature #%d", index+1), + } + + switch dkim.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DKIM signature is valid" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your DKIM signature is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DKIM signature validation failed" + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") + } + + var detailsParts []string + if dkim.Domain != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) + } + if dkim.Selector != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) + } + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + } + + switch dmarc.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DMARC validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your DMARC policy is properly aligned") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DMARC validation failed" + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Configure DMARC policy for your domain") + } + + if dmarc.Domain != nil { + details := fmt.Sprintf("Domain: %s", *dmarc.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "BIMI (Brand Indicators)", + } + + switch bimi.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "BIMI validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") + case api.AuthResultResultFail: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "BIMI validation failed" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") + } + + if bimi.Domain != nil { + details := fmt.Sprintf("Domain: %s", *bimi.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "ARC (Authenticated Received Chain)", + } + + switch arc.Result { + case api.ARCResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) + check.Message = "ARC chain validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication") + case api.ARCResultResultFail: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = "ARC chain validation failed" + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No ARC chain present" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries") + } + + // Build details + var detailsParts []string + if arc.ChainLength != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength)) + } + if arc.ChainValid != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid)) + } + if arc.Details != nil { + detailsParts = append(detailsParts, *arc.Details) + } + + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go new file mode 100644 index 0000000..17ac24e --- /dev/null +++ b/pkg/analyzer/authentication_test.go @@ -0,0 +1,846 @@ +// 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" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "DKIM pass with domain and selector", + part: "dkim=pass header.d=example.com header.s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "DKIM fail", + part: "dkim=fail header.d=example.com header.s=selector1", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "selector1", + }, + { + name: "DKIM with short form (d= and s=)", + part: "dkim=pass d=example.com s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + }) + } +} + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "BIMI pass with domain and selector", + part: "bimi=pass header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI with short form (d= and selector=)", + part: "bimi=pass d=example.com selector=v1", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: api.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} + +func TestGenerateAuthSPFCheck(t *testing.T) { + tests := []struct { + name string + spf *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "SPF pass", + spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "SPF fail", + spf: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "SPF softfail", + spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + { + name: "SPF neutral", + spf: &api.AuthResult{ + Result: api.AuthResultResultNeutral, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateSPFCheck(tt.spf) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "SPF Record" { + t.Errorf("Name = %q, want %q", check.Name, "SPF Record") + } + }) + } +} + +func TestGenerateAuthDKIMCheck(t *testing.T) { + tests := []struct { + name string + dkim *api.AuthResult + index int + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "DKIM pass", + dkim: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "DKIM fail", + dkim: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "DKIM none", + dkim: &api.AuthResult{ + Result: api.AuthResultResultNone, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDKIMCheck(tt.dkim, tt.index) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if !strings.Contains(check.Name, "DKIM Signature") { + t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name) + } + }) + } +} + +func TestGenerateAuthDMARCCheck(t *testing.T) { + tests := []struct { + name string + dmarc *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "DMARC pass", + dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "DMARC fail", + dmarc: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDMARCCheck(tt.dmarc) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "DMARC Policy" { + t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy") + } + }) + } +} + +func TestGenerateAuthBIMICheck(t *testing.T) { + tests := []struct { + name string + bimi *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "BIMI pass", + bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // BIMI doesn't contribute to score + }, + { + name: "BIMI fail", + bimi: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + { + name: "BIMI none", + bimi: &api.AuthResult{ + Result: api.AuthResultResultNone, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateBIMICheck(tt.bimi) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "BIMI (Brand Indicators)" { + t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)") + } + + // BIMI should always have score of 0.0 (branding feature) + if check.Score != 0.0 { + t.Error("BIMI should not contribute to deliverability score") + } + }) + } +} + +func TestGetAuthenticationScore(t *testing.T) { + tests := []struct { + name string + results *api.AuthenticationResults + expectedScore float32 + }{ + { + 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: 3.0, + }, + { + name: "SPF and DKIM only", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 2.0, + }, + { + name: "SPF fail, DKIM pass", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultFail, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 1.0, + }, + { + name: "SPF softfail", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, + }, + }, + expectedScore: 0.5, + }, + { + name: "No authentication", + results: &api.AuthenticationResults{}, + expectedScore: 0.0, + }, + { + name: "BIMI doesn't affect score", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedScore: 1.0, // Only SPF counted, not BIMI + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := scorer.GetAuthenticationScore(tt.results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} + +func TestGenerateAuthenticationChecks(t *testing.T) { + tests := []struct { + name string + results *api.AuthenticationResults + expectedChecks int + }{ + { + name: "All authentication methods present", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedChecks: 4, // SPF, DKIM, DMARC, BIMI + }, + { + name: "Without BIMI", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedChecks: 3, // SPF, DKIM, DMARC + }, + { + name: "No authentication results", + results: &api.AuthenticationResults{}, + expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing + }, + { + name: "With ARC", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Arc: &api.ARCResult{ + Result: api.ARCResultResultPass, + ChainLength: api.PtrTo(2), + ChainValid: api.PtrTo(true), + }, + }, + expectedChecks: 4, // SPF, DKIM, DMARC, ARC + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateAuthenticationChecks(tt.results) + + if len(checks) != tt.expectedChecks { + t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks) + } + + // Verify all checks have the Authentication category + for _, check := range checks { + if check.Category != api.Authentication { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication) + } + } + }) + } +} + +func 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) + } + }) + } +} + +func TestGenerateARCCheck(t *testing.T) { + tests := []struct { + name string + arc *api.ARCResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "ARC pass", + arc: &api.ARCResult{ + Result: api.ARCResultResultPass, + ChainLength: api.PtrTo(2), + ChainValid: api.PtrTo(true), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // ARC doesn't contribute to score + }, + { + name: "ARC fail", + arc: &api.ARCResult{ + Result: api.ARCResultResultFail, + ChainLength: api.PtrTo(1), + ChainValid: api.PtrTo(false), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + { + name: "ARC none", + arc: &api.ARCResult{ + Result: api.ARCResultResultNone, + ChainLength: api.PtrTo(0), + ChainValid: api.PtrTo(true), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateARCCheck(tt.arc) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if !strings.Contains(check.Name, "ARC") { + t.Errorf("Name should contain 'ARC', got %q", check.Name) + } + }) + } +} diff --git a/internal/analyzer/content.go b/pkg/analyzer/content.go similarity index 96% rename from internal/analyzer/content.go rename to pkg/analyzer/content.go index bad38c9..ac46259 100644 --- a/internal/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -507,7 +507,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api if !results.HTMLValid { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "HTML structure is invalid" if len(results.HTMLErrors) > 0 { details := strings.Join(results.HTMLErrors, "; ") @@ -517,7 +517,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api } else { check.Status = api.CheckStatusPass check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "HTML structure is valid" check.Advice = api.PtrTo("Your HTML is well-formed") } @@ -552,7 +552,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec if brokenLinks > 0 { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) 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) @@ -560,7 +560,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec } else if warningLinks > 0 { check.Status = api.CheckStatusWarn check.Score = 0.3 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) 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) @@ -568,7 +568,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec } else { check.Status = api.CheckStatusPass check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links)) check.Advice = api.PtrTo("Your links are working properly") } @@ -601,7 +601,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che if noAltCount == len(results.Images) { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) 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)) @@ -609,7 +609,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che } else if noAltCount > 0 { check.Status = api.CheckStatusWarn check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) 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)) @@ -617,7 +617,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All images have alt attributes" check.Advice = api.PtrTo("Your images are properly tagged for accessibility") } @@ -636,13 +636,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api. if !results.HasUnsubscribe { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) 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.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks)) check.Advice = api.PtrTo("Your email includes an unsubscribe option") } @@ -662,7 +662,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) if consistency < 0.3 { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) 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) @@ -670,7 +670,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) 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) @@ -693,7 +693,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C if ratio > 10.0 { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) 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) @@ -701,7 +701,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C } else if ratio > 5.0 { check.Status = api.CheckStatusWarn check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) 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) @@ -709,7 +709,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) 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) @@ -730,7 +730,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count) check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails") diff --git a/internal/analyzer/content_test.go b/pkg/analyzer/content_test.go similarity index 100% rename from internal/analyzer/content_test.go rename to pkg/analyzer/content_test.go diff --git a/internal/analyzer/dns.go b/pkg/analyzer/dns.go similarity index 73% rename from internal/analyzer/dns.go rename to pkg/analyzer/dns.go index 07c0346..9a6d26f 100644 --- a/internal/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -58,6 +58,7 @@ type DNSResults struct { SPFRecord *SPFRecord DKIMRecords []DKIMRecord DMARCRecord *DMARCRecord + BIMIRecord *BIMIRecord Errors []string } @@ -93,6 +94,17 @@ type DMARCRecord struct { Error string } +// BIMIRecord represents a BIMI record +type BIMIRecord struct { + Selector string + Domain string + Record string + LogoURL string // URL to the brand logo (SVG) + VMCURL string // URL to Verified Mark Certificate (optional) + Valid bool + Error string +} + // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { // Extract domain from From address @@ -128,6 +140,9 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // Check DMARC record results.DMARCRecord = d.checkDMARCRecord(domain) + // Check BIMI record (using default selector) + results.BIMIRecord = d.checkBIMIRecord(domain, "default") + return results } @@ -395,6 +410,89 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { return true } +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), + } + } + + if len(txtRecords) == 0 { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: "No BIMI record found", + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: false, + Error: "BIMI record appears malformed", + } + } + + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} + // GenerateDNSChecks generates check results for DNS validation func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { var checks []api.Check @@ -421,6 +519,11 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) } + // BIMI record check (optional) + if results.BIMIRecord != nil { + checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) + } + return checks } @@ -434,7 +537,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { check.Message = results.MXRecords[0].Error @@ -445,7 +548,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { } else { check.Status = api.CheckStatusPass check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) // Add details about MX records @@ -474,14 +577,14 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = spf.Error - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") } else { // If record exists but is invalid, it's a warning check.Status = api.CheckStatusWarn check.Score = 0.5 check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } @@ -489,7 +592,7 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid SPF record found" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &spf.Record check.Advice = api.PtrTo("Your SPF record is properly configured") } @@ -508,7 +611,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { 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.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details @@ -516,7 +619,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid DKIM record found" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details check.Advice = api.PtrTo("Your DKIM record is properly published") @@ -536,13 +639,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = dmarc.Error - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &dmarc.Record // Provide advice based on policy @@ -564,3 +667,53 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { return check } + +// generateBIMICheck creates a check for BIMI records +func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "BIMI Record", + } + + if !bimi.Valid { + // BIMI is optional, so missing record is just informational + if bimi.Record == "" { + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No BIMI record found (optional)" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") + } else { + // If record exists but is invalid + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") + check.Details = &bimi.Record + } + } else { + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "Valid BIMI record found" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + + // Build details with logo and VMC URLs + var detailsParts []string + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) + if bimi.LogoURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) + } + if bimi.VMCURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) + check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") + } else { + check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") + } + + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/dns_test.go b/pkg/analyzer/dns_test.go similarity index 75% rename from internal/analyzer/dns_test.go rename to pkg/analyzer/dns_test.go index fe501d5..12a6bd0 100644 --- a/internal/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -631,3 +631,190 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) { t.Error("Expected error when no domain can be extracted") } } + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestGenerateBIMICheck(t *testing.T) { + tests := []struct { + name string + bimi *BIMIRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid BIMI with logo only", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg", + LogoURL: "https://example.com/logo.svg", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // BIMI doesn't contribute to score + }, + { + name: "Valid BIMI with VMC", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + LogoURL: "https://example.com/logo.svg", + VMCURL: "https://example.com/vmc.pem", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, + }, + { + name: "No BIMI record (optional)", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Valid: false, + Error: "No BIMI record found", + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + { + name: "Invalid BIMI record", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1", + Valid: false, + Error: "BIMI record appears malformed", + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateBIMICheck(tt.bimi) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + if check.Name != "BIMI Record" { + t.Errorf("Name = %q, want %q", check.Name, "BIMI Record") + } + + // Check details for valid BIMI with VMC + if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil { + if !strings.Contains(*check.Details, "VMC URL") { + t.Error("Details should contain VMC URL for valid BIMI with VMC") + } + } + }) + } +} diff --git a/internal/analyzer/parser.go b/pkg/analyzer/parser.go similarity index 100% rename from internal/analyzer/parser.go rename to pkg/analyzer/parser.go diff --git a/internal/analyzer/parser_test.go b/pkg/analyzer/parser_test.go similarity index 100% rename from internal/analyzer/parser_test.go rename to pkg/analyzer/parser_test.go diff --git a/internal/analyzer/rbl.go b/pkg/analyzer/rbl.go similarity index 96% rename from internal/analyzer/rbl.go rename to pkg/analyzer/rbl.go index be7366c..fb01ae0 100644 --- a/internal/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -279,7 +279,7 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Status: api.CheckStatusWarn, Score: 1.0, Message: "No public IP addresses found to check", - Severity: api.PtrTo(api.Low), + Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), }) return checks @@ -316,22 +316,22 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { 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.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your sending IP has a good reputation") } else if listedCount == 1 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") } else if listedCount <= 3 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") } else { check.Status = api.CheckStatusFail check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") } @@ -357,15 +357,15 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { // Determine severity based on which RBL if strings.Contains(rblCheck.RBL, "spamhaus") { - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") check.Advice = &advice } else if strings.Contains(rblCheck.RBL, "spamcop") { - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") check.Advice = &advice } else { - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) check.Advice = &advice } diff --git a/internal/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go similarity index 99% rename from internal/analyzer/rbl_test.go rename to pkg/analyzer/rbl_test.go index a75ef19..3a2fd44 100644 --- a/internal/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -419,7 +419,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.Critical, + expectedSeverity: api.CheckSeverityCritical, }, { name: "SpamCop listing", @@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, + expectedSeverity: api.CheckSeverityHigh, }, { name: "Other RBL listing", @@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, + expectedSeverity: api.CheckSeverityHigh, }, } diff --git a/internal/analyzer/report.go b/pkg/analyzer/report.go similarity index 100% rename from internal/analyzer/report.go rename to pkg/analyzer/report.go diff --git a/internal/analyzer/report_test.go b/pkg/analyzer/report_test.go similarity index 100% rename from internal/analyzer/report_test.go rename to pkg/analyzer/report_test.go diff --git a/internal/analyzer/scoring.go b/pkg/analyzer/scoring.go similarity index 91% rename from internal/analyzer/scoring.go rename to pkg/analyzer/scoring.go index 07f6a34..03ab870 100644 --- a/internal/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -72,8 +72,7 @@ func (s *DeliverabilityScorer) CalculateScore( } // Calculate individual scores - authAnalyzer := NewAuthenticationAnalyzer() - result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults) + result.AuthScore = s.GetAuthenticationScore(authResults) spamAnalyzer := NewSpamAssassinAnalyzer() result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) @@ -351,13 +350,13 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) if len(missing) == 0 { check.Status = api.CheckStatusPass check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All required headers are present" check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") } else { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) @@ -386,13 +385,13 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa if len(missing) == 0 { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All recommended headers are present" check.Advice = api.PtrTo("Your email includes all recommended headers") } else if len(missing) < len(recommendedHeaders) { check.Status = api.CheckStatusWarn check.Score = 0.15 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) @@ -400,7 +399,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa } else { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Missing all recommended headers" check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") } @@ -420,20 +419,20 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C if messageID == "" { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Message = "Message-ID header is missing" check.Advice = api.PtrTo("Add a unique Message-ID header to your email") } else if !s.isValidMessageID(messageID) { check.Status = api.CheckStatusWarn check.Score = 0.05 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Message-ID format is invalid" check.Advice = api.PtrTo("Use proper Message-ID format: ") check.Details = &messageID } else { check.Status = api.CheckStatusPass check.Score = 0.1 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Message-ID is properly formatted" check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") check.Details = &messageID @@ -452,13 +451,13 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a if len(email.Parts) == 0 { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "No MIME parts detected" check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") } else { check.Status = api.CheckStatusPass check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) check.Advice = api.PtrTo("Your email has proper MIME structure") @@ -504,3 +503,43 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { return summary.String() } + +// GetAuthenticationScore calculates the authentication score (0-3 points) +func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { + var score float32 = 0.0 + + // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 1.0 + case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: + score += 0.5 + } + } + + // DKIM: 1 point for at least one pass + if results.Dkim != nil && len(*results.Dkim) > 0 { + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + score += 1.0 + break + } + } + } + + // DMARC: 1 point for pass + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + score += 1.0 + } + } + + // Cap at 3 points maximum + if score > 3.0 { + score = 3.0 + } + + return score +} diff --git a/internal/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go similarity index 100% rename from internal/analyzer/scoring_test.go rename to pkg/analyzer/scoring_test.go diff --git a/internal/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go similarity index 89% rename from internal/analyzer/spamassassin.go rename to pkg/analyzer/spamassassin.go index 78a6a72..00cab21 100644 --- a/internal/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // Parse X-Spam-Report header for detailed test results if reportHeader, ok := headers["X-Spam-Report"]; ok { - result.RawReport = reportHeader + result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1) a.parseSpamReport(reportHeader, result) } @@ -140,20 +140,25 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass // Format varies, but typically: // * 1.5 TEST_NAME Description of test // * 0.0 TEST_NAME2 Description +// Note: mail.Header.Get() joins continuation lines, so newlines are removed. +// We split on '*' to separate individual tests. func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) { - // Split by lines - lines := strings.Split(report, "\n") + // The report header has been joined by mail.Header.Get(), so we split on '*' + // Each segment starting with '*' is either a test line or continuation + segments := strings.Split(report, "*") - // Regex to match test lines: * score TEST_NAME Description - testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + // 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+(.*)$`) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { continue } - matches := testRe.FindStringSubmatch(line) + // Try to match as a test line + matches := testRe.FindStringSubmatch(segment) if len(matches) > 3 { testName := matches[2] score, _ := strconv.ParseFloat(matches[1], 64) @@ -217,7 +222,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe Status: api.CheckStatusWarn, Score: 0.0, Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), }) return checks @@ -260,27 +265,27 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) 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.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") } else if score < required { check.Status = api.CheckStatusPass check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your email passes spam filters") } else if score < required*1.5 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") } else if score < required*2 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") } else { check.Status = api.CheckStatusFail check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") } @@ -307,10 +312,10 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Negative indicator (increases spam score) if detail.Score > 2.0 { check.Status = api.CheckStatusFail - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) } else { check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) } check.Score = 0.0 check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) @@ -320,7 +325,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Positive indicator (decreases spam score) check.Status = api.CheckStatusPass check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) check.Advice = &advice diff --git a/internal/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go similarity index 68% rename from internal/analyzer/spamassassin_test.go rename to pkg/analyzer/spamassassin_test.go index 4682ed3..e7491db 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" @@ -480,6 +481,176 @@ func TestGenerateTestCheck(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) + + // Validate that we got a result + if result == nil { + t.Fatal("Expected SpamAssassin result, got nil") + } + + // Validate IsSpam flag (should be false for this email) + if result.IsSpam { + t.Error("IsSpam should be false for real_example.eml") + } + + // Validate score (should be -0.1) + expectedScore := -0.1 + if result.Score != expectedScore { + t.Errorf("Score = %v, want %v", result.Score, expectedScore) + } + + // Validate required score (should be 5.0) + expectedRequired := 5.0 + if result.RequiredScore != expectedRequired { + t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired) + } + + // Validate version + if !strings.Contains(result.Version, "SpamAssassin") { + t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version) + } + + // 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]float64{ + "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 == "" { + t.Errorf("Test %s should have a description", testName) + } + } + + // Test GetSpamAssassinScore + score := analyzer.GetSpamAssassinScore(result) + if score != 2.0 { + t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) + } + + // Test GenerateSpamAssassinChecks + checks := analyzer.GenerateSpamAssassinChecks(result) + if len(checks) < 1 { + t.Fatal("Expected at least 1 check, got none") + } + + // Main check should be PASS with excellent score + mainCheck := checks[0] + if mainCheck.Status != api.CheckStatusPass { + t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass) + } + if mainCheck.Category != api.Spam { + t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) + } + if !strings.Contains(mainCheck.Message, "spam score") { + t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message) + } + if mainCheck.Score != 2.0 { + t.Errorf("Main check score = %v, want 2.0", mainCheck.Score) + } + + // Log all checks for debugging + t.Logf("Generated %d checks:", len(checks)) + for i, check := range checks { + t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)", + i+1, check.Name, check.Message, check.Score, check.Status) + } +} + // Helper function to compare string slices func stringSliceEqual(a, b []string) bool { if len(a) != len(b) { 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..3fbf1f1 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4444 @@ +{ + "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": "^1.4.0", + "@eslint/js": "^9.36.0", + "@hey-api/openapi-ts": "0.85.2", + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/node": "^22", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", + "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^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.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hey-api/codegen-core": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz", + "integrity": "sha512-c7VjBy/8ed0EVLNgaeS9Xxams1Tuv/WK/b4xXH3Qr4wjzYeJUtxOcoP8YdwNLavqKP8pGiuctjX2Z1Pwc4jMgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.10.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.0", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.0.tgz", + "integrity": "sha512-BMnIuhVgNmSudadw1GcTsP18Yk5l8FrYrg/OSYNxz0D2E0vf4D5e4j5nUbuY8MU6p1vp7ev0xrfP6A/NWazkzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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.85.2", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.85.2.tgz", + "integrity": "sha512-pNu+DOtjeXiGhMqSQ/mYadh6BuKR/QiucVunyA2P7w2uyxkfCJ9sHS20Y72KHXzB3nshKJ9r7JMirysoa50SJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "^0.2.0", + "@hey-api/json-schema-ref-parser": "1.2.0", + "ansi-colors": "4.1.3", + "c12": "3.3.0", + "color-support": "1.1.3", + "commander": "13.0.0", + "handlebars": "4.7.8", + "open": "10.1.2", + "semver": "7.7.2" + }, + "bin": { + "openapi-ts": "bin/index.cjs" + }, + "engines": { + "node": ">=18.0.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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@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.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", + "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "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.47.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.2.tgz", + "integrity": "sha512-mbUomaJTiADTrq6GT4ZvQ7v1rs0S+wXGMzrjFwjARAKMEF8FpOUmz2uEJ4M9WMJMQOXCMHpKFzJfdjo9O7M22A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.3.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", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.17", + "vitefu": "^1.1.1" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", + "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "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": "22.18.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", + "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", + "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/type-utils": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", + "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", + "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.1", + "@typescript-eslint/types": "^8.46.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", + "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1" + }, + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", + "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", + "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", + "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", + "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.1", + "@typescript-eslint/tsconfig-utils": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", + "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", + "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "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/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz", + "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^17.2.2", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.5.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.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "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": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.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.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "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.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "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.4.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", + "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.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.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.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.12.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.5.tgz", + "integrity": "sha512-4KRG84eAHQfYd9OjZ1K7sCHy0nox+9KwT+s5WCCku3jTim5RV4tVENob274nCwIaApXsYPKAUAZFBxKZ3Wyfjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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-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/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.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", + "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "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/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "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": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "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-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "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.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "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.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "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.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.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.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "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.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "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.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", + "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "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.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/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/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "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.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "dev": true, + "license": "MIT" + }, + "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.41.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.41.0.tgz", + "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "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.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", + "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", + "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.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", + "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "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.18.3" + }, + "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.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "dev": true, + "license": "MIT" + }, + "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/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "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.46.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz", + "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.46.1", + "@typescript-eslint/parser": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/utils": "8.46.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "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.1.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", + "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.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/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..d0a2578 --- /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": "^1.4.0", + "@eslint/js": "^9.36.0", + "@hey-api/openapi-ts": "0.85.2", + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/node": "^22", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.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..754c1b2 --- /dev/null +++ b/web/routes.go @@ -0,0 +1,171 @@ +// 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" + "io" + "io/fs" + "io/ioutil" + "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 + CustomHeadHTML = "" +) + +func DeclareRoutes(cfg *config.Config, router *gin.Engine) { + appConfig := map[string]interface{}{} + + 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("/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 != "" { + 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, _ := ioutil.ReadAll(resp.Body) + + v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + + indexTpl = template.Must(template.New("index.html").Parse(v2)) + + if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ + "Head": CustomHeadHTML, + }); 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, _ := ioutil.ReadAll(f) + + v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + + indexTpl = template.Must(template.New("index.html").Parse(v2)) + } + + // Serve template + if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ + "Head": CustomHeadHTML, + }); 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..ddae5b6 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,152 @@ +:root { + --bs-primary: #1cb487; + --bs-primary-rgb: 28, 180, 135; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* 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 { + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.card:hover { + 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..1966776 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte new file mode 100644 index 0000000..bc5741c --- /dev/null +++ b/web/src/lib/components/CheckCard.svelte @@ -0,0 +1,74 @@ + + +
+
+
+
+ +
+
+
+
+
{check.name}
+ {check.category} +
+ {check.score.toFixed(1)} pts +
+ +

{check.message}

+ + {#if check.advice} + + {/if} + + {#if check.details} +
+ Technical Details +
{check.details}
+
+ {/if} +
+
+
+
+ + diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte new file mode 100644 index 0000000..aa79f9e --- /dev/null +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -0,0 +1,46 @@ + + +
+
+ {email} + +
+ {#if copied} + + Copied to clipboard! + + {/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/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/PendingState.svelte b/web/src/lib/components/PendingState.svelte new file mode 100644 index 0000000..ab9a6f8 --- /dev/null +++ b/web/src/lib/components/PendingState.svelte @@ -0,0 +1,108 @@ + + +
+
+
+
+
+ +
+ +

Waiting for Your Email

+

Send your test email to the address below:

+ +
+ +
+ + + +
+
+ Checking for email every 3 seconds... +
+
+
+ + +
+
+
+ What we'll check: +
+
+
+
    +
  • + SPF, DKIM, DMARC +
  • +
  • + DNS Records +
  • +
  • + SpamAssassin Score +
  • +
+
+
+
    +
  • + Blacklist Status +
  • +
  • + Content Quality +
  • +
  • + Header Validation +
  • +
+
+
+
+
+
+
+ + diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte new file mode 100644 index 0000000..65aa706 --- /dev/null +++ b/web/src/lib/components/ScoreCard.svelte @@ -0,0 +1,71 @@ + + +
+
+

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

+

{getScoreLabel(score)}

+

Overall Deliverability Score

+ + {#if summary} +
+
+
+ Authentication + {summary.authentication_score.toFixed(1)}/3 +
+
+
+
+ Spam Score + {summary.spam_score.toFixed(1)}/2 +
+
+
+
+ Blacklists + {summary.blacklist_score.toFixed(1)}/2 +
+
+
+
+ Content + {summary.content_score.toFixed(1)}/2 +
+
+
+
+ Headers + {summary.header_score.toFixed(1)}/1 +
+
+
+ {/if} +
+
diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte new file mode 100644 index 0000000..3d4872c --- /dev/null +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -0,0 +1,65 @@ + + +
+
+
+ SpamAssassin Analysis +
+
+
+
+
+ Score: + + {spamassassin.score.toFixed(2)} / {spamassassin.required_score.toFixed(1)} + +
+
+ Classified as: + + {spamassassin.is_spam ? "SPAM" : "HAM"} + +
+
+ + {#if spamassassin.tests && spamassassin.tests.length > 0} +
+ Tests Triggered: +
+ {#each spamassassin.tests as test} + {test} + {/each} +
+
+ {/if} + + {#if spamassassin.report} +
+ Full Report +
{spamassassin.report}
+
+ {/if} +
+
+ + diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts new file mode 100644 index 0000000..8da4188 --- /dev/null +++ b/web/src/lib/components/index.ts @@ -0,0 +1,8 @@ +// Component exports +export { default as FeatureCard } from "./FeatureCard.svelte"; +export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; +export { default as ScoreCard } from "./ScoreCard.svelte"; +export { default as CheckCard } from "./CheckCard.svelte"; +export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; +export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; +export { default as PendingState } from "./PendingState.svelte"; diff --git a/web/src/lib/hey-api.ts b/web/src/lib/hey-api.ts new file mode 100644 index 0000000..e75e70a --- /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(url: string, init: RequestInit): Promise { + const response = await fetch(url, 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/routes/+error.svelte b/web/src/routes/+error.svelte new file mode 100644 index 0000000..5d0514c --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,150 @@ + + + + {status} - {getErrorTitle(status)} | happyDeliver + + +
+
+
+ +
+ +
+ + +

{status}

+ + +

{getErrorTitle(status)}

+ + +

{getErrorDescription(status)}

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

Looking for something specific?

+ +
+ {/if} +
+
+
+ + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..9ed83d4 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,51 @@ + + +
+ + +
+ {@render children?.()} +
+ + +
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..8da8dc2 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,222 @@ + + + + happyDeliver - Email Deliverability Testing + + + +
+
+
+
+

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/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte new file mode 100644 index 0000000..f70bc53 --- /dev/null +++ b/web/src/routes/test/[test]/+page.svelte @@ -0,0 +1,143 @@ + + + + {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."} + + +
+ {#if loading} +
+
+ Loading... +
+

Loading test...

+
+ {:else if error} +
+
+ +
+
+ {:else if test && test.status !== "analyzed"} + + + {:else if report} + +
+ +
+
+ +
+
+ + +
+
+

Detailed Checks

+ {#each report.checks as check} + + {/each} +
+
+ + + {#if report.spamassassin} +
+
+ +
+
+ {/if} + + + +
+ {/if} +
+ + 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}"], + }, + }, + ], + }, +});