From e1356ebc2297531774011240bba03c292daf987e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 15:17:49 +0700 Subject: [PATCH 001/256] Handle config, parse flags --- cmd/happyDeliver/main.go | 18 ++-- internal/config/cli.go | 48 +++++++++++ internal/config/config.go | 176 ++++++++++++++++++++++++++++++++++++++ internal/config/custom.go | 45 ++++++++++ internal/config/env.go | 42 +++++++++ internal/config/file.go | 54 ++++++++++++ 6 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 internal/config/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/config/custom.go create mode 100644 internal/config/env.go create mode 100644 internal/config/file.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 3dc3fae..63445d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -31,12 +31,12 @@ func main() { fmt.Println("Mail Tester - Email Deliverability Testing Platform") fmt.Println("Version: 0.1.0-dev") - if len(os.Args) < 2 { - printUsage() - os.Exit(1) + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err.Error()) } - command := os.Args[1] + command := flag.Arg(0) switch command { case "server": @@ -55,8 +55,10 @@ func main() { } func printUsage() { - fmt.Println("\nUsage:") - fmt.Println(" mailtester server - Start the API server") - fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)") - fmt.Println(" mailtester version - Print version information") + fmt.Println("\nCommand availables:") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)") + fmt.Println(" happyDeliver version - Print version information") + fmt.Println("") + flag.Usage() } diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..eee2c4c --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,48 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets") + flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket") + flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres") + flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path") + flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") + flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") + flag.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)") + + // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..493c333 --- /dev/null +++ b/internal/config/config.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 config + +import ( + "flag" + "fmt" + "log" + "os" + "path" + "strings" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Config represents the application configuration +type Config struct { + DevProxy string + Bind string + Database DatabaseConfig + Email EmailConfig + Analysis AnalysisConfig +} + +// DatabaseConfig contains database connection settings +type DatabaseConfig struct { + Type string + DSN string +} + +// EmailConfig contains email domain and routing settings +type EmailConfig struct { + Domain string + TestAddressPrefix string +} + +// AnalysisConfig contains timeout and behavior settings for email analysis +type AnalysisConfig struct { + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string +} + +// DefaultConfig returns a configuration with sensible defaults +func DefaultConfig() *Config { + return &Config{ + DevProxy: "", + Bind: ":8081", + Database: DatabaseConfig{ + Type: "sqlite", + DSN: "happydeliver.db", + }, + Email: EmailConfig{ + Domain: "happydeliver.local", + TestAddressPrefix: "test-", + }, + Analysis: AnalysisConfig{ + DNSTimeout: 5 * time.Second, + HTTPTimeout: 10 * time.Second, + RBLs: []string{}, + }, + } +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = DefaultConfig() + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "happydeliver.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append( + configLocations, + path.Join(home, "happydeliver", "happydeliver.conf"), + path.Join(home, "happydomain", "happydeliver.conf"), + ) + } + + configLocations = append(configLocations, path.Join("etc", "happydeliver.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + if _, e := os.Stat(filename); !os.IsNotExist(e) { + log.Printf("Loading configuration from %s\n", filename) + err = parseFile(opts, filename) + if err != nil { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + return +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Email.Domain == "" { + return fmt.Errorf("email domain cannot be empty") + } + + if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil { + return fmt.Errorf("invalid email domain: %w", err) + } + + if c.Database.Type != "sqlite" && c.Database.Type != "postgres" { + return fmt.Errorf("unsupported database type: %s", c.Database.Type) + } + + if c.Database.DSN == "" { + return fmt.Errorf("database DSN cannot be empty") + } + + return nil +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + if len(value) == 0 { + return + } + + key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..9461632 --- /dev/null +++ b/internal/config/custom.go @@ -0,0 +1,45 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "strings" +) + +type StringArray struct { + Array *[]string +} + +func (i *StringArray) String() string { + if i.Array == nil { + return "" + } + + return fmt.Sprintf("%v", *i.Array) +} + +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, strings.Split(value, ",")...) + + return nil +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..cd4c344 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,42 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by HAPPYDELIVER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..ec28a58 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +} From 395ea2122ea579eb62aaa2181ab4e1b3b89bb3d5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 15 Oct 2025 16:16:29 +0700 Subject: [PATCH 002/256] Glue things together --- README.md | 163 ++++++++++++++++++++++++++ cmd/happyDeliver/main.go | 105 +++++++++++++++-- go.mod | 24 ++-- go.sum | 49 +++++--- internal/api/handlers.go | 215 ++++++++++++++++++++++++++++++++++ internal/api/helpers.go | 4 + internal/receiver/receiver.go | 204 ++++++++++++++++++++++++++++++++ internal/storage/models.go | 73 ++++++++++++ internal/storage/storage.go | 163 ++++++++++++++++++++++++++ 9 files changed, 972 insertions(+), 28 deletions(-) create mode 100644 README.md create mode 100644 internal/api/handlers.go create mode 100644 internal/receiver/receiver.go create mode 100644 internal/storage/models.go create mode 100644 internal/storage/storage.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ea16aa --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# happyDeliver + +An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring. + +## Features + +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **REST API**: Full-featured API for creating tests and retrieving reports +- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails +- **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 + +### 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 Transport rule + +You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html). + +1. Append the following lines at the end of your `master.cf` file: + + ```diff + + + +# happyDeliver analyzer - receives emails matching transport_maps + +happydeliver unix - n n - - pipe + + flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient} + ``` + +2. Create the file `/etc/postfix/transport_happyDeliver` with the following content: + + ``` + # Transport map - route test emails to happyDeliver analyzer + # Pattern: test-@yourdomain.com -> happydeliver pipe + + /^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver: + ``` + +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_maps + ``` + + If your `transport_maps` option is not set, just append this line: + + ``` + transport_maps = pcre:/etc/postfix/transport_maps + ``` + + Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. + +#### Postfix Aliases + +You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`). + +Add the following line in your `/etc/postfix/aliases`: + +```diff ++test: "|/path/to/happyDeliver analyze" +``` + +Note that the recipient address has to be present in header. + +### 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 (MDA Mode) + +To process an email from an MTA pipe: + +```bash +cat email.eml | ./happyDeliver analyze +``` + +Or specify recipient explicitly: + +```bash +cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com +``` + +## 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 + +**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/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 63445d1..c837ca4 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -22,14 +22,25 @@ package main import ( + "flag" "fmt" + "io" "log" "os" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/receiver" + "git.happydns.org/happyDeliver/internal/storage" ) +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 +51,11 @@ func main() { switch command { case "server": - log.Println("Starting API server...") - // TODO: Start API server + runServer(cfg) case "analyze": - log.Println("Starting email analyzer...") - // TODO: Start email analyzer (LMTP/pipe mode) + runAnalyzer(cfg) case "version": - fmt.Println("0.1.0-dev") + fmt.Println(version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -54,6 +63,88 @@ func main() { } } +func runServer(cfg *config.Config) { + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + log.Fatalf("Failed to initialize storage: %v", err) + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // 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) + + // Start 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 { + log.Fatalf("Failed to start server: %v", err) + } +} + +func runAnalyzer(cfg *config.Config) { + // Parse command-line flags + fs := flag.NewFlagSet("analyze", flag.ExitOnError) + recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)") + fs.Parse(flag.Args()[1:]) + + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + log.Fatalf("Failed to initialize storage: %v", err) + } + defer store.Close() + + log.Printf("Email analyzer ready, reading from stdin...") + + // Read email from stdin + emailData, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("Failed to read email from stdin: %v", err) + } + + // If recipient not provided, try to extract from headers + var recipient string + if *recipientEmail != "" { + recipient = *recipientEmail + } else { + recipient, err = receiver.ExtractRecipientFromHeaders(emailData) + if err != nil { + log.Fatalf("Failed to extract recipient: %v", err) + } + log.Printf("Extracted recipient: %s", recipient) + } + + // Process the email + recv := receiver.NewEmailReceiver(store, cfg) + if err := recv.ProcessEmailBytes(emailData, recipient); err != nil { + log.Fatalf("Failed to process email: %v", err) + } + + log.Println("Email processed successfully") +} + func printUsage() { fmt.Println("\nCommand availables:") fmt.Println(" happyDeliver server - Start the API server") diff --git a/go.mod b/go.mod index 8a2e2d9..74c97cd 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,10 @@ require ( 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.45.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.0 ) require ( @@ -25,12 +28,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 @@ -48,12 +58,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..4b1490e 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,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 +100,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= @@ -143,6 +157,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 +177,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 +189,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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 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 +211,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 +257,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..ed97bc6 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,215 @@ +// 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 + testID := uuid.New() + + // Generate test email address + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + testID.String(), + h.config.Email.Domain, + ) + + // Create test in database + test, err := h.storage.CreateTest(testID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to create test", + Details: stringPtr(err.Error()), + }) + return + } + + // Return response + c.JSON(http.StatusCreated, TestResponse{ + Id: test.ID, + 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) { + test, err := h.storage.GetTest(id) + if err != nil { + if err == storage.ErrNotFound { + c.JSON(http.StatusNotFound, Error{ + Error: "not_found", + Message: "Test not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve test", + Details: stringPtr(err.Error()), + }) + return + } + + // Convert storage status to API status + var apiStatus TestStatus + switch test.Status { + case storage.StatusPending: + apiStatus = TestStatusPending + case storage.StatusReceived: + apiStatus = TestStatusReceived + case storage.StatusAnalyzed: + apiStatus = TestStatusAnalyzed + case storage.StatusFailed: + apiStatus = TestStatusFailed + default: + apiStatus = TestStatusPending + } + + // Generate test email address + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + test.ID.String(), + h.config.Email.Domain, + ) + + c.JSON(http.StatusOK, Test{ + Id: test.ID, + Email: openapi_types.Email(email), + Status: apiStatus, + CreatedAt: test.CreatedAt, + UpdatedAt: &test.UpdatedAt, + }) +} + +// 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 + dbStatus := StatusComponentsDatabaseUp + if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound { + 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/receiver/receiver.go b/internal/receiver/receiver.go new file mode 100644 index 0000000..55a03ec --- /dev/null +++ b/internal/receiver/receiver.go @@ -0,0 +1,204 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package receiver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "regexp" + "strings" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/analyzer" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// EmailReceiver handles incoming emails from the MTA +type EmailReceiver struct { + storage storage.Storage + config *config.Config +} + +// NewEmailReceiver creates a new email receiver +func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver { + return &EmailReceiver{ + storage: store, + config: 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) + + // Verify test exists and is in pending status + test, err := r.storage.GetTest(testID) + if err != nil { + return fmt.Errorf("test not found: %w", err) + } + + if test.Status != storage.StatusPending { + return fmt.Errorf("test is not in pending status (current: %s)", test.Status) + } + + // Update test status to received + if err := r.storage.UpdateTestStatus(testID, storage.StatusReceived); err != nil { + return fmt.Errorf("failed to update test status: %w", err) + } + + log.Printf("Analyzing email for test %s", testID) + + // Parse the email + emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail)) + if err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + return fmt.Errorf("failed to parse email: %w", err) + } + + // Create report generator with configuration + generator := analyzer.NewReportGenerator( + r.config.Analysis.DNSTimeout, + r.config.Analysis.HTTPTimeout, + r.config.Analysis.RBLs, + ) + + // Analyze the email + results := generator.AnalyzeEmail(emailMsg) + + // Generate the report + report := generator.GenerateReport(testID, results) + + log.Printf("Analysis complete. Score: %.2f/10", report.Score) + + // Marshal report to JSON + reportJSON, err := json.Marshal(report) + if err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + return fmt.Errorf("failed to marshal report: %w", err) + } + + // Store the report + if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + 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..546bf2f --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package storage + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// TestStatus represents the status of a deliverability test +type TestStatus string + +const ( + StatusPending TestStatus = "pending" + StatusReceived TestStatus = "received" + StatusAnalyzed TestStatus = "analyzed" + StatusFailed TestStatus = "failed" +) + +// Test represents a deliverability test instance +type Test struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Status TestStatus `gorm:"type:varchar(20);not null;index"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Report *Report `gorm:"foreignKey:TestID;constraint:OnDelete:CASCADE"` +} + +// BeforeCreate is a GORM hook that generates a UUID before creating a test +func (t *Test) BeforeCreate(tx *gorm.DB) error { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + return nil +} + +// 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"` + 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..ff06edc --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,163 @@ +// 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" + + "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 { + // Test operations + CreateTest(id uuid.UUID) (*Test, error) + GetTest(id uuid.UUID) (*Test, error) + UpdateTestStatus(id uuid.UUID, status TestStatus) error + + // Report operations + CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) + GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err 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(&Test{}, &Report{}); err != nil { + return nil, fmt.Errorf("failed to migrate database schema: %w", err) + } + + return &DBStorage{db: db}, nil +} + +// CreateTest creates a new test with pending status +func (s *DBStorage) CreateTest(id uuid.UUID) (*Test, error) { + test := &Test{ + ID: id, + Status: StatusPending, + } + + if err := s.db.Create(test).Error; err != nil { + return nil, fmt.Errorf("failed to create test: %w", err) + } + + return test, nil +} + +// GetTest retrieves a test by ID +func (s *DBStorage) GetTest(id uuid.UUID) (*Test, error) { + var test Test + if err := s.db.First(&test, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get test: %w", err) + } + return &test, nil +} + +// UpdateTestStatus updates the status of a test +func (s *DBStorage) UpdateTestStatus(id uuid.UUID, status TestStatus) error { + result := s.db.Model(&Test{}).Where("id = ?", id).Update("status", status) + if result.Error != nil { + return fmt.Errorf("failed to update test status: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return 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) + } + + // Update test status to analyzed + if err := s.UpdateTestStatus(testID, StatusAnalyzed); err != nil { + return nil, fmt.Errorf("failed to update test status: %w", err) + } + + return dbReport, 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 +} + +// Close closes the database connection +func (s *DBStorage) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} From 6abb95c62524c21e09f0459f979bebc0d6d96e9b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 16 Oct 2025 17:47:37 +0700 Subject: [PATCH 003/256] Add AIO Dockerfile --- .dockerignore | 27 +++++ .gitignore | 3 + Dockerfile | 86 +++++++++++++++ README.md | 65 +++++++++++- docker-compose.yml | 40 +++++++ docker/README.md | 164 +++++++++++++++++++++++++++++ docker/entrypoint.sh | 66 ++++++++++++ docker/opendkim/opendkim.conf | 39 +++++++ docker/opendmarc/opendmarc.conf | 41 ++++++++ docker/postfix/aliases | 10 ++ docker/postfix/main.cf | 41 ++++++++ docker/postfix/master.cf | 87 +++++++++++++++ docker/postfix/transport_maps | 4 + docker/spamassassin/local.cf | 50 +++++++++ docker/supervisor/supervisord.conf | 76 +++++++++++++ 15 files changed, 794 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/README.md create mode 100644 docker/entrypoint.sh create mode 100644 docker/opendkim/opendkim.conf create mode 100644 docker/opendmarc/opendmarc.conf create mode 100644 docker/postfix/aliases create mode 100644 docker/postfix/main.cf create mode 100644 docker/postfix/master.cf create mode 100644 docker/postfix/transport_maps create mode 100644 docker/spamassassin/local.cf create mode 100644 docker/supervisor/supervisord.conf 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/.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..2bbb87b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# Multi-stage Dockerfile for happyDeliver with integrated MTA +# Stage 1: 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 . . + +# Build the application +RUN go generate ./... && \ + CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver + +# Stage 2: 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 + +# 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 index 6ea16aa..93c1c43 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,62 @@ An open-source email deliverability testing platform that analyzes test emails a ## Quick Start -### 1. Build +### 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 @@ -28,7 +83,7 @@ go build -o happyDeliver ./cmd/happyDeliver The server will start on `http://localhost:8080` by default. -### 3. Integrate with your existing e-mail setup +#### 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. @@ -84,7 +139,7 @@ Add the following line in your `/etc/postfix/aliases`: Note that the recipient address has to be present in header. -### 4. Create a Test +#### 4. Create a Test ```bash curl -X POST http://localhost:8080/api/test @@ -100,11 +155,11 @@ Response: } ``` -### 5. Send Test Email +#### 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 +#### 6. Get Report ```bash curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4ba64c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +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 + # Optional: Override config + # - ./custom-config.yaml:/etc/happydeliver/config.yaml + + restart: unless-stopped + + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + data: + logs: diff --git a/docker/README.md b/docker/README.md 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..a12b13f --- /dev/null +++ b/docker/postfix/master.cf @@ -0,0 +1,87 @@ +# 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} + +# happyDeliver analyzer - receives emails matching transport_maps +happydeliver unix - n n - - pipe + flags=DRXhu user=happydeliver argv=/usr/local/bin/happyDeliver analyze -config /etc/happydeliver/config.yaml -recipient ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps new file mode 100644 index 0000000..c12f4cc --- /dev/null +++ b/docker/postfix/transport_maps @@ -0,0 +1,4 @@ +# Transport map - route test emails to happyDeliver analyzer +# Pattern: test-@domain.com -> happydeliver pipe + +/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ happydeliver: 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" From 4cd184779ed5ca8a9bc201ae705aaa659759f432 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 17:18:37 +0700 Subject: [PATCH 004/256] Web UI setup --- Dockerfile | 17 +- cmd/happyDeliver/main.go | 2 + web/.gitignore | 26 + web/.npmrc | 1 + web/.prettierignore | 9 + web/.prettierrc | 13 + web/assets.go | 43 + web/eslint.config.js | 41 + web/openapi-ts.config.ts | 12 + web/package-lock.json | 5532 ++++++++++++++++++++++++++++++++++++++ web/package.json | 43 + web/routes.go | 171 ++ web/src/app.d.ts | 13 + web/src/app.html | 11 + web/src/lib/hey-api.ts | 30 + web/src/lib/index.ts | 1 + web/svelte.config.js | 19 + web/tsconfig.json | 19 + web/vite.config.ts | 25 + 19 files changed, 6026 insertions(+), 2 deletions(-) create mode 100644 web/.gitignore create mode 100644 web/.npmrc create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc create mode 100644 web/assets.go create mode 100644 web/eslint.config.js create mode 100644 web/openapi-ts.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/routes.go create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/lib/hey-api.ts create mode 100644 web/src/lib/index.ts create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/Dockerfile b/Dockerfile index 2bbb87b..e731aa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,17 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA -# Stage 1: Build the Go application +# 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 @@ -13,12 +25,13 @@ 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 2: Runtime image with Postfix and all filters +# Stage 3: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index c837ca4..da8ccb1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -34,6 +34,7 @@ import ( "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/receiver" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/web" ) const version = "0.1.0-dev" @@ -89,6 +90,7 @@ func runServer(cfg *config.Config) { // Register API routes apiGroup := router.Group("/api") api.RegisterHandlers(apiGroup, handler) + web.DeclareRoutes(cfg, router) // Start server log.Printf("Starting API server on %s", cfg.Bind) 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..b1719e9 --- /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: "./src/lib/hey-api.ts", + }, + ], +}); diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3129b3f --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5532 @@ +{ + "name": "happyDeliver", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "happyDeliver", + "version": "0.1.0", + "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.80.0", + "@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/@bufbuild/protobuf": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", + "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "optional": true, + "peer": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "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.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "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.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "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/json-schema-ref-parser": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", + "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.0.tgz", + "integrity": "sha512-sX0TFKCvwMyh10C1mmqYR2TBaHla//72kocuPpRM5ya38LqRaqkMW9A0hjcrZTrzFtjYtz2Pdr3in+JrsM3TLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/json-schema-ref-parser": "1.0.6", + "ansi-colors": "4.1.3", + "c12": "2.0.1", + "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.18.0 || ^20.9.0 || >=22.10.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/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "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.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "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.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", + "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", + "dev": true, + "license": "MIT", + "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", + "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", + "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", + "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/runner/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/@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/snapshot/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/@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", + "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/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11", + "optional": true, + "peer": true + }, + "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": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.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/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "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.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "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/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/devalue": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", + "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "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.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.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", + "@types/json-schema": "^7.0.15", + "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.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", + "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "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.3.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/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/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/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": "1.2.5", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", + "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", + "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.5.4", + "pathe": "^2.0.3", + "tar": "^6.2.1" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/giget/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/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/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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/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/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/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/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-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", + "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "tinyexec": "^0.3.2", + "ufo": "^1.5.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/nypm/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/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "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": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "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": "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/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/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/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", + "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", + "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.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "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.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", + "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.93.2", + "sass-embedded-android-arm": "1.93.2", + "sass-embedded-android-arm64": "1.93.2", + "sass-embedded-android-riscv64": "1.93.2", + "sass-embedded-android-x64": "1.93.2", + "sass-embedded-darwin-arm64": "1.93.2", + "sass-embedded-darwin-x64": "1.93.2", + "sass-embedded-linux-arm": "1.93.2", + "sass-embedded-linux-arm64": "1.93.2", + "sass-embedded-linux-musl-arm": "1.93.2", + "sass-embedded-linux-musl-arm64": "1.93.2", + "sass-embedded-linux-musl-riscv64": "1.93.2", + "sass-embedded-linux-musl-x64": "1.93.2", + "sass-embedded-linux-riscv64": "1.93.2", + "sass-embedded-linux-x64": "1.93.2", + "sass-embedded-unknown-all": "1.93.2", + "sass-embedded-win32-arm64": "1.93.2", + "sass-embedded-win32-x64": "1.93.2" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", + "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", + "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", + "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", + "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", + "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", + "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", + "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", + "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", + "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", + "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", + "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", + "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", + "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", + "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", + "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", + "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "peer": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", + "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", + "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "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.40.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", + "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "dev": true, + "license": "MIT", + "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-check/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-check/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", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", + "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "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" + }, + "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/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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": "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/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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true, + "peer": true + }, + "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", + "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/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "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/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vite": { + "version": "7.1.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", + "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "dev": true, + "license": "MIT", + "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/vite-node/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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "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/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/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/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/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "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..1687702 --- /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.80.0", + "@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.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/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/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}"], + }, + }, + ], + }, +}); From 6a4909c1a72ff4038b64a69f42e89249fd85380b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 18:45:53 +0700 Subject: [PATCH 005/256] Implement web ui --- web/src/app.css | 152 ++++++++++++ web/src/lib/components/CheckCard.svelte | 74 ++++++ .../lib/components/EmailAddressDisplay.svelte | 46 ++++ web/src/lib/components/FeatureCard.svelte | 33 +++ web/src/lib/components/HowItWorksStep.svelte | 17 ++ web/src/lib/components/PendingState.svelte | 115 ++++++++++ web/src/lib/components/ScoreCard.svelte | 71 ++++++ .../lib/components/SpamAssassinCard.svelte | 65 ++++++ web/src/lib/components/index.ts | 8 + web/src/routes/+error.svelte | 150 ++++++++++++ web/src/routes/+layout.svelte | 51 +++++ web/src/routes/+page.svelte | 216 ++++++++++++++++++ web/src/routes/test/[test]/+page.svelte | 143 ++++++++++++ 13 files changed, 1141 insertions(+) create mode 100644 web/src/app.css create mode 100644 web/src/lib/components/CheckCard.svelte create mode 100644 web/src/lib/components/EmailAddressDisplay.svelte create mode 100644 web/src/lib/components/FeatureCard.svelte create mode 100644 web/src/lib/components/HowItWorksStep.svelte create mode 100644 web/src/lib/components/PendingState.svelte create mode 100644 web/src/lib/components/ScoreCard.svelte create mode 100644 web/src/lib/components/SpamAssassinCard.svelte create mode 100644 web/src/lib/components/index.ts create mode 100644 web/src/routes/+error.svelte create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/test/[test]/+page.svelte 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/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..a5075e8 --- /dev/null +++ b/web/src/lib/components/PendingState.svelte @@ -0,0 +1,115 @@ + + +
+
+
+
+
+ +
+ +

Waiting for Your Email

+

Send your test email to the address below:

+ +
+ +
+ + + + {#if test.status === "received"} + + {/if} + +
+
+ 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/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..f0709a1 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,216 @@ + + + + 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} +
+ + From 924d80bdca1dcc90a11f4c5ec803284bbadf268a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:41:07 +0700 Subject: [PATCH 006/256] Refactor main.go --- cmd/happyDeliver/main.go | 105 +++---------------------- internal/analyzer/analyzer.go | 87 +++++++++++++++++++++ internal/app/cli_analyzer.go | 143 ++++++++++++++++++++++++++++++++++ internal/app/server.go | 74 ++++++++++++++++++ internal/receiver/receiver.go | 34 +++----- 5 files changed, 325 insertions(+), 118 deletions(-) create mode 100644 internal/analyzer/analyzer.go create mode 100644 internal/app/cli_analyzer.go create mode 100644 internal/app/server.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index da8ccb1..01d99f1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -24,17 +24,11 @@ package main import ( "flag" "fmt" - "io" "log" "os" - "github.com/gin-gonic/gin" - - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/receiver" - "git.happydns.org/happyDeliver/internal/storage" - "git.happydns.org/happyDeliver/web" ) const version = "0.1.0-dev" @@ -52,9 +46,13 @@ func main() { switch command { case "server": - runServer(cfg) + if err := app.RunServer(cfg); err != nil { + log.Fatalf("Server error: %v", err) + } case "analyze": - runAnalyzer(cfg) + if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { + log.Fatalf("Analyzer error: %v", err) + } case "version": fmt.Println(version) default: @@ -64,94 +62,11 @@ func main() { } } -func runServer(cfg *config.Config) { - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Connected to %s database", cfg.Database.Type) - - // 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 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 { - log.Fatalf("Failed to start server: %v", err) - } -} - -func runAnalyzer(cfg *config.Config) { - // Parse command-line flags - fs := flag.NewFlagSet("analyze", flag.ExitOnError) - recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)") - fs.Parse(flag.Args()[1:]) - - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Email analyzer ready, reading from stdin...") - - // Read email from stdin - emailData, err := io.ReadAll(os.Stdin) - if err != nil { - log.Fatalf("Failed to read email from stdin: %v", err) - } - - // If recipient not provided, try to extract from headers - var recipient string - if *recipientEmail != "" { - recipient = *recipientEmail - } else { - recipient, err = receiver.ExtractRecipientFromHeaders(emailData) - if err != nil { - log.Fatalf("Failed to extract recipient: %v", err) - } - log.Printf("Extracted recipient: %s", recipient) - } - - // Process the email - recv := receiver.NewEmailReceiver(store, cfg) - if err := recv.ProcessEmailBytes(emailData, recipient); err != nil { - log.Fatalf("Failed to process email: %v", err) - } - - log.Println("Email processed successfully") -} - 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/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..3588280 --- /dev/null +++ b/internal/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/app/cli_analyzer.go b/internal/app/cli_analyzer.go new file mode 100644 index 0000000..87a4e0a --- /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/analyzer" + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// 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..9c7d28b --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,74 @@ +// 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 ( + "log" + "os" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/web" +) + +// RunServer starts the API server 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) + + // 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/receiver/receiver.go b/internal/receiver/receiver.go index 55a03ec..325ef31 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,7 +22,6 @@ package receiver import ( - "bytes" "encoding/json" "fmt" "io" @@ -39,15 +38,17 @@ import ( // EmailReceiver handles incoming emails from the MTA type EmailReceiver struct { - storage storage.Storage - config *config.Config + 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, + storage: store, + config: cfg, + analyzer: analyzer.NewEmailAnalyzer(cfg), } } @@ -92,33 +93,20 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analyzing email for test %s", testID) - // Parse the email - emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail)) + // Analyze the email using the shared analyzer + result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { log.Printf("Failed to update test status to failed: %v", updateErr) } - return fmt.Errorf("failed to parse email: %w", err) + return fmt.Errorf("failed to analyze email: %w", err) } - // Create report generator with configuration - generator := analyzer.NewReportGenerator( - r.config.Analysis.DNSTimeout, - r.config.Analysis.HTTPTimeout, - r.config.Analysis.RBLs, - ) - - // Analyze the email - results := generator.AnalyzeEmail(emailMsg) - - // Generate the report - report := generator.GenerateReport(testID, results) - - log.Printf("Analysis complete. Score: %.2f/10", report.Score) + log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) // Marshal report to JSON - reportJSON, err := json.Marshal(report) + reportJSON, err := json.Marshal(result.Report) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { From f8e6a2f314b58b4032578756dc1b034e35eab79c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:41:28 +0700 Subject: [PATCH 007/256] Add LMTP server --- README.md | 49 ++++++------ docker/postfix/master.cf | 4 - docker/postfix/transport_maps | 6 +- go.mod | 2 + go.sum | 4 + internal/app/server.go | 10 ++- internal/config/cli.go | 1 + internal/config/config.go | 2 + internal/lmtp/server.go | 144 ++++++++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 35 deletions(-) create mode 100644 internal/lmtp/server.go diff --git a/README.md b/README.md index 93c1c43..c76e248 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a - **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports -- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails +- **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 @@ -90,54 +90,47 @@ happyDeliver will not perform thoses checks, it relies instead on standard softw Choose one of the following way to integrate happyDeliver in your existing setup: -#### Postfix Transport rule +#### Postfix LMTP Transport -You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html). +You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP. -1. Append the following lines at the end of your `master.cf` file: +1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`): - ```diff - + - +# happyDeliver analyzer - receives emails matching transport_maps - +happydeliver unix - n n - - pipe - + flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient} + ```bash + ./happyDeliver server ``` -2. Create the file `/etc/postfix/transport_happyDeliver` with the following content: + 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 analyzer - # Pattern: test-@yourdomain.com -> happydeliver pipe + # Transport map - route test emails to happyDeliver LMTP server + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver: + /^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_maps + +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_maps + transport_maps = pcre:/etc/postfix/transport_happydeliver ``` Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. -#### Postfix Aliases +4. Reload Postfix configuration: -You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`). - -Add the following line in your `/etc/postfix/aliases`: - -```diff -+test: "|/path/to/happyDeliver analyze" -``` - -Note that the recipient address has to be present in header. + ```bash + postfix reload + ``` #### 4. Create a Test @@ -175,9 +168,9 @@ curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 | `/api/report/{id}/raw` | GET | Get raw annotated email | | `/api/status` | GET | Service health and status | -## Email Analyzer (MDA Mode) +## Email Analyzer (CLI Mode) -To process an email from an MTA pipe: +For manual testing or debugging, you can analyze emails from the command line: ```bash cat email.eml | ./happyDeliver analyze @@ -189,6 +182,8 @@ Or specify recipient explicitly: 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: diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index a12b13f..92976a4 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -81,7 +81,3 @@ policy-spf unix - n n - 0 spawn # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} - -# happyDeliver analyzer - receives emails matching transport_maps -happydeliver unix - n n - - pipe - flags=DRXhu user=happydeliver argv=/usr/local/bin/happyDeliver analyze -config /etc/happydeliver/config.yaml -recipient ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index c12f4cc..49fdb98 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ -# Transport map - route test emails to happyDeliver analyzer -# Pattern: test-@domain.com -> happydeliver pipe +# 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}@.*$/ happydeliver: +/^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/go.mod b/go.mod index 74c97cd..ce87ef6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 @@ -19,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 diff --git a/go.sum b/go.sum index 4b1490e..cf49874 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= diff --git a/internal/app/server.go b/internal/app/server.go index 9c7d28b..8db4b59 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -29,11 +29,12 @@ import ( "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 server +// RunServer starts the API server and LMTP server func RunServer(cfg *config.Config) error { if err := cfg.Validate(); err != nil { return err @@ -48,6 +49,13 @@ func RunServer(cfg *config.Config) error { log.Printf("Connected to %s database", cfg.Database.Type) + // 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) diff --git a/internal/config/cli.go b/internal/config/cli.go index eee2c4c..cfd5908 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -33,6 +33,7 @@ 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)") diff --git a/internal/config/config.go b/internal/config/config.go index 493c333..39ee07c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ type DatabaseConfig struct { type EmailConfig struct { Domain string TestAddressPrefix string + LMTPAddr string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -73,6 +74,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 +} From bb1dd2a85e29a14e9c84ec531ab8b83ca0cb455c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:56:22 +0700 Subject: [PATCH 008/256] Create test on email arrival --- api/openapi.yaml | 18 +++---- internal/api/handlers.go | 55 ++++++-------------- internal/receiver/receiver.go | 28 +++------- internal/storage/models.go | 33 ++---------- internal/storage/storage.go | 60 ++++------------------ web/src/lib/components/PendingState.svelte | 7 --- 6 files changed, 46 insertions(+), 155 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f027f1a..467f62c 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 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index ed97bc6..79d839e 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -53,7 +53,7 @@ func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { // CreateTest creates a new deliverability test // (POST /test) func (h *APIHandler) CreateTest(c *gin.Context) { - // Generate a unique test ID + // Generate a unique test ID (no database record created) testID := uuid.New() // Generate test email address @@ -63,20 +63,9 @@ func (h *APIHandler) CreateTest(c *gin.Context) { h.config.Email.Domain, ) - // Create test in database - test, err := h.storage.CreateTest(testID) - if err != nil { - c.JSON(http.StatusInternalServerError, Error{ - Error: "internal_error", - Message: "Failed to create test", - Details: stringPtr(err.Error()), - }) - return - } - // Return response c.JSON(http.StatusCreated, TestResponse{ - Id: test.ID, + Id: testID, Email: openapi_types.Email(email), Status: TestResponseStatusPending, Message: stringPtr("Send your test email to the address above"), @@ -86,51 +75,41 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // GetTest retrieves test metadata // (GET /test/{id}) func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { - test, err := h.storage.GetTest(id) + // Check if a report exists for this test ID + reportExists, err := h.storage.ReportExists(id) if err != nil { - if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ - Error: "not_found", - Message: "Test not found", - }) - return - } c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", - Message: "Failed to retrieve test", + Message: "Failed to check test status", Details: stringPtr(err.Error()), }) return } - // Convert storage status to API status + // Determine status based on report existence var apiStatus TestStatus - switch test.Status { - case storage.StatusPending: - apiStatus = TestStatusPending - case storage.StatusReceived: - apiStatus = TestStatusReceived - case storage.StatusAnalyzed: + if reportExists { apiStatus = TestStatusAnalyzed - case storage.StatusFailed: - apiStatus = TestStatusFailed - default: + } else { apiStatus = TestStatusPending } // Generate test email address email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - test.ID.String(), + 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: test.ID, + Id: id, Email: openapi_types.Email(email), Status: apiStatus, - CreatedAt: test.CreatedAt, - UpdatedAt: &test.UpdatedAt, + CreatedAt: now, + UpdatedAt: &now, }) } @@ -187,9 +166,9 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // Calculate uptime uptime := int(time.Since(h.startTime).Seconds()) - // Check database connectivity + // Check database connectivity by trying to check if a report exists dbStatus := StatusComponentsDatabaseUp - if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound { + if _, err := h.storage.ReportExists(uuid.New()); err != nil { dbStatus = StatusComponentsDatabaseDown } diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 325ef31..db1c2ea 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -76,19 +76,15 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Extracted test ID: %s", testID) - // Verify test exists and is in pending status - test, err := r.storage.GetTest(testID) + // Check if a report already exists for this test ID + reportExists, err := r.storage.ReportExists(testID) if err != nil { - return fmt.Errorf("test not found: %w", err) + return fmt.Errorf("failed to check report existence: %w", err) } - if test.Status != storage.StatusPending { - return fmt.Errorf("test is not in pending status (current: %s)", test.Status) - } - - // Update test status to received - if err := r.storage.UpdateTestStatus(testID, storage.StatusReceived); err != nil { - return fmt.Errorf("failed to update test status: %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) @@ -96,10 +92,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string // Analyze the email using the shared analyzer result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) if err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to analyze email: %w", err) } @@ -108,19 +100,11 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to marshal report: %w", err) } // Store the report if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to store report: %w", err) } diff --git a/internal/storage/models.go b/internal/storage/models.go index 546bf2f..dbb3daa 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -28,39 +28,12 @@ import ( "gorm.io/gorm" ) -// TestStatus represents the status of a deliverability test -type TestStatus string - -const ( - StatusPending TestStatus = "pending" - StatusReceived TestStatus = "received" - StatusAnalyzed TestStatus = "analyzed" - StatusFailed TestStatus = "failed" -) - -// Test represents a deliverability test instance -type Test struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey"` - Status TestStatus `gorm:"type:varchar(20);not null;index"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - Report *Report `gorm:"foreignKey:TestID;constraint:OnDelete:CASCADE"` -} - -// BeforeCreate is a GORM hook that generates a UUID before creating a test -func (t *Test) BeforeCreate(tx *gorm.DB) error { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - return nil -} - // 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"` - RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers - ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data + 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"` } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ff06edc..7550463 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -38,14 +38,10 @@ var ( // Storage interface defines operations for persisting and retrieving data type Storage interface { - // Test operations - CreateTest(id uuid.UUID) (*Test, error) - GetTest(id uuid.UUID) (*Test, error) - UpdateTestStatus(id uuid.UUID, status TestStatus) error - // 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) // Close closes the database connection Close() error @@ -75,51 +71,13 @@ func NewStorage(dbType, dsn string) (Storage, error) { } // Auto-migrate the schema - if err := db.AutoMigrate(&Test{}, &Report{}); err != nil { + if err := db.AutoMigrate(&Report{}); err != nil { return nil, fmt.Errorf("failed to migrate database schema: %w", err) } return &DBStorage{db: db}, nil } -// CreateTest creates a new test with pending status -func (s *DBStorage) CreateTest(id uuid.UUID) (*Test, error) { - test := &Test{ - ID: id, - Status: StatusPending, - } - - if err := s.db.Create(test).Error; err != nil { - return nil, fmt.Errorf("failed to create test: %w", err) - } - - return test, nil -} - -// GetTest retrieves a test by ID -func (s *DBStorage) GetTest(id uuid.UUID) (*Test, error) { - var test Test - if err := s.db.First(&test, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("failed to get test: %w", err) - } - return &test, nil -} - -// UpdateTestStatus updates the status of a test -func (s *DBStorage) UpdateTestStatus(id uuid.UUID, status TestStatus) error { - result := s.db.Model(&Test{}).Where("id = ?", id).Update("status", status) - if result.Error != nil { - return fmt.Errorf("failed to update test status: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrNotFound - } - return nil -} - // CreateReport creates a new report for a test func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) { dbReport := &Report{ @@ -132,14 +90,18 @@ func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON [ return nil, fmt.Errorf("failed to create report: %w", err) } - // Update test status to analyzed - if err := s.UpdateTestStatus(testID, StatusAnalyzed); err != nil { - return nil, fmt.Errorf("failed to update test status: %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 diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index a5075e8..ab9a6f8 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -30,13 +30,6 @@ transactional emails, etc.) for the most accurate results. - {#if test.status === "received"} - - {/if} -
Checking for email every 3 seconds... From 10238b5b549ef0831d5e7656a6b130a5fc45726e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 03:07:38 +0000 Subject: [PATCH 009/256] Add renovate.json --- renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 renovate.json 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" + ] +} From 0adddc60a0fce4e8c51e1baff2ae5100399d4e56 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 05:10:18 +0000 Subject: [PATCH 010/256] Update module github.com/quic-go/quic-go to v0.54.1 [SECURITY] --- go.mod | 5 ++--- go.sum | 10 ++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index ce87ef6..e51b1d5 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ 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 @@ -15,13 +14,13 @@ require ( ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/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/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -52,7 +51,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 diff --git a/go.sum b/go.sum index cf49874..939e263 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -88,7 +84,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -144,8 +139,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= @@ -154,7 +149,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 99988a7fd244c0f1a8006ca81013ec6b86ea9b34 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 05:10:49 +0000 Subject: [PATCH 011/256] Update dependency @hey-api/openapi-ts to v0.85.2 --- web/package-lock.json | 10840 ++++++++++++++++++++-------------------- web/package.json | 2 +- 2 files changed, 5311 insertions(+), 5531 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 3129b3f..714f1c0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,5532 +1,5312 @@ { - "name": "happyDeliver", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "happyDeliver", - "version": "0.1.0", - "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.80.0", - "@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/@bufbuild/protobuf": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", - "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", - "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)", - "optional": true, - "peer": true - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", - "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.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "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.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", - "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.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", - "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.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "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/json-schema-ref-parser": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", - "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", - "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.80.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.0.tgz", - "integrity": "sha512-sX0TFKCvwMyh10C1mmqYR2TBaHla//72kocuPpRM5ya38LqRaqkMW9A0hjcrZTrzFtjYtz2Pdr3in+JrsM3TLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/json-schema-ref-parser": "1.0.6", - "ansi-colors": "4.1.3", - "c12": "2.0.1", - "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.18.0 || ^20.9.0 || >=22.10.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/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "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.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", - "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.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", - "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", - "dev": true, - "license": "MIT", - "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", - "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", - "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", - "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/runner/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/@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/snapshot/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/@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", - "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/buffer-builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "dev": true, - "license": "MIT/X11", - "optional": true, - "peer": true - }, - "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": "2.0.1", - "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", - "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.1", - "confbox": "^0.1.7", - "defu": "^6.1.4", - "dotenv": "^16.4.5", - "giget": "^1.2.3", - "jiti": "^2.3.0", - "mlly": "^1.7.1", - "ohash": "^1.1.4", - "pathe": "^1.1.2", - "perfect-debounce": "^1.0.0", - "pkg-types": "^1.2.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/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "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/colorjs.io": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", - "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", - "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.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "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/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/devalue": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", - "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "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.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.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", - "@types/json-schema": "^7.0.15", - "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.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", - "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", - "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.3.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/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/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/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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": "1.2.5", - "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", - "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", - "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.5.4", - "pathe": "^2.0.3", - "tar": "^6.2.1" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/giget/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/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/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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/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/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/mlly/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/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-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/nypm": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", - "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "tinyexec": "^0.3.2", - "ufo": "^1.5.4" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, - "node_modules/nypm/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/ohash": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", - "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", - "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": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "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": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "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": "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/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/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/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", - "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", - "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.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "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.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", - "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/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "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/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-embedded": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", - "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@bufbuild/protobuf": "^2.5.0", - "buffer-builder": "^0.2.0", - "colorjs.io": "^0.5.0", - "immutable": "^5.0.2", - "rxjs": "^7.4.0", - "supports-color": "^8.1.1", - "sync-child-process": "^1.0.2", - "varint": "^6.0.0" - }, - "bin": { - "sass": "dist/bin/sass.js" - }, - "engines": { - "node": ">=16.0.0" - }, - "optionalDependencies": { - "sass-embedded-all-unknown": "1.93.2", - "sass-embedded-android-arm": "1.93.2", - "sass-embedded-android-arm64": "1.93.2", - "sass-embedded-android-riscv64": "1.93.2", - "sass-embedded-android-x64": "1.93.2", - "sass-embedded-darwin-arm64": "1.93.2", - "sass-embedded-darwin-x64": "1.93.2", - "sass-embedded-linux-arm": "1.93.2", - "sass-embedded-linux-arm64": "1.93.2", - "sass-embedded-linux-musl-arm": "1.93.2", - "sass-embedded-linux-musl-arm64": "1.93.2", - "sass-embedded-linux-musl-riscv64": "1.93.2", - "sass-embedded-linux-musl-x64": "1.93.2", - "sass-embedded-linux-riscv64": "1.93.2", - "sass-embedded-linux-x64": "1.93.2", - "sass-embedded-unknown-all": "1.93.2", - "sass-embedded-win32-arm64": "1.93.2", - "sass-embedded-win32-x64": "1.93.2" - } - }, - "node_modules/sass-embedded-all-unknown": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", - "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", - "cpu": [ - "!arm", - "!arm64", - "!riscv64", - "!x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-android-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", - "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", - "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", - "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", - "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", - "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", - "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", - "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", - "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", - "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", - "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", - "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", - "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", - "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", - "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-unknown-all": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", - "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "!android", - "!darwin", - "!linux", - "!win32" - ], - "peer": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", - "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", - "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "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.40.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", - "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", - "dev": true, - "license": "MIT", - "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-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/svelte-check/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", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/svelte-eslint-parser": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", - "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", - "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" - }, - "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/sync-child-process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", - "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "sync-message-port": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "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": "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/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/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "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/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, - "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", - "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/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, - "license": "MIT" - }, - "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/varint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/vite": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", - "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", - "dev": true, - "license": "MIT", - "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/vite-node/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/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "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/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/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/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/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "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" - } - } + "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/@bufbuild/protobuf": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", + "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "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.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "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.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "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/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "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.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "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.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", + "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", + "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/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11", + "optional": true + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "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/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "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/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/devalue": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", + "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "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.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.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", + "@types/json-schema": "^7.0.15", + "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.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", + "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "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.3.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/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/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "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/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-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "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/nypm/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/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": "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/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.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "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.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", + "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.93.2", + "sass-embedded-android-arm": "1.93.2", + "sass-embedded-android-arm64": "1.93.2", + "sass-embedded-android-riscv64": "1.93.2", + "sass-embedded-android-x64": "1.93.2", + "sass-embedded-darwin-arm64": "1.93.2", + "sass-embedded-darwin-x64": "1.93.2", + "sass-embedded-linux-arm": "1.93.2", + "sass-embedded-linux-arm64": "1.93.2", + "sass-embedded-linux-musl-arm": "1.93.2", + "sass-embedded-linux-musl-arm64": "1.93.2", + "sass-embedded-linux-musl-riscv64": "1.93.2", + "sass-embedded-linux-musl-x64": "1.93.2", + "sass-embedded-linux-riscv64": "1.93.2", + "sass-embedded-linux-x64": "1.93.2", + "sass-embedded-unknown-all": "1.93.2", + "sass-embedded-win32-arm64": "1.93.2", + "sass-embedded-win32-x64": "1.93.2" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", + "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", + "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", + "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", + "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", + "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", + "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", + "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", + "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", + "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", + "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", + "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", + "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", + "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", + "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", + "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", + "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", + "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", + "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "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.40.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", + "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "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-check/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", + "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "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" + }, + "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/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "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/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/vite": { + "version": "7.1.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", + "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "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/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/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 index 1687702..d0a2578 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.80.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", From ac57440c2e5bfaa1979ed12a23d21f9b63cdc09d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 12:25:27 +0700 Subject: [PATCH 012/256] Add an auto-cleanup worker --- internal/app/cleanup.go | 108 ++++++++++++++++++++++++++++++++++++ internal/app/server.go | 7 +++ internal/config/cli.go | 1 + internal/config/config.go | 16 +++--- internal/storage/storage.go | 11 ++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 internal/app/cleanup.go 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/server.go b/internal/app/server.go index 8db4b59..332516b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -22,6 +22,7 @@ package app import ( + "context" "log" "os" @@ -49,6 +50,12 @@ func RunServer(cfg *config.Config) error { 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 { diff --git a/internal/config/cli.go b/internal/config/cli.go index cfd5908..93c18ce 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -37,6 +37,7 @@ func declareFlags(o *Config) { flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") + flag.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 39ee07c..d59045b 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 @@ -65,8 +66,9 @@ type AnalysisConfig struct { // DefaultConfig returns a configuration with sensible defaults func DefaultConfig() *Config { return &Config{ - DevProxy: "", - Bind: ":8081", + DevProxy: "", + Bind: ":8081", + ReportRetention: 0, // Keep reports forever by default Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7550463..7c27279 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -24,6 +24,7 @@ package storage import ( "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/driver/postgres" @@ -42,6 +43,7 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) + DeleteOldReports(olderThan time.Time) (int64, error) // Close closes the database connection Close() error @@ -115,6 +117,15 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { 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() From afe3b81304f248f7939b18d5190d69d9a4117212 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 17:34:07 +0700 Subject: [PATCH 013/256] Add CI/CD --- .drone-manifest.yml | 22 +++++++ .drone.yml | 156 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 .drone-manifest.yml create mode 100644 .drone.yml 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 From 1e35912b8a9f37dff2b8159d43e8347af31c547b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 07:10:05 +0000 Subject: [PATCH 014/256] Update dependency svelte to v5.41.0 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 714f1c0..48bb286 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4644,9 +4644,9 @@ } }, "node_modules/svelte": { - "version": "5.40.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", - "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "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, From 449a8a2c67683c433990531d4685423a1f11bb8d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 15:17:49 +0700 Subject: [PATCH 015/256] Handle config, parse flags --- cmd/happyDeliver/main.go | 18 ++-- internal/config/cli.go | 48 +++++++++++ internal/config/config.go | 176 ++++++++++++++++++++++++++++++++++++++ internal/config/custom.go | 45 ++++++++++ internal/config/env.go | 42 +++++++++ internal/config/file.go | 54 ++++++++++++ 6 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 internal/config/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/config/custom.go create mode 100644 internal/config/env.go create mode 100644 internal/config/file.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 3dc3fae..63445d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -31,12 +31,12 @@ func main() { fmt.Println("Mail Tester - Email Deliverability Testing Platform") fmt.Println("Version: 0.1.0-dev") - if len(os.Args) < 2 { - printUsage() - os.Exit(1) + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err.Error()) } - command := os.Args[1] + command := flag.Arg(0) switch command { case "server": @@ -55,8 +55,10 @@ func main() { } func printUsage() { - fmt.Println("\nUsage:") - fmt.Println(" mailtester server - Start the API server") - fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)") - fmt.Println(" mailtester version - Print version information") + fmt.Println("\nCommand availables:") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)") + fmt.Println(" happyDeliver version - Print version information") + fmt.Println("") + flag.Usage() } diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..eee2c4c --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,48 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets") + flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket") + flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres") + flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path") + flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") + flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") + flag.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)") + + // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a866fbd --- /dev/null +++ b/internal/config/config.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 config + +import ( + "flag" + "fmt" + "log" + "os" + "path" + "strings" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Config represents the application configuration +type Config struct { + DevProxy string + Bind string + Database DatabaseConfig + Email EmailConfig + Analysis AnalysisConfig +} + +// DatabaseConfig contains database connection settings +type DatabaseConfig struct { + Type string + DSN string +} + +// EmailConfig contains email domain and routing settings +type EmailConfig struct { + Domain string + TestAddressPrefix string +} + +// AnalysisConfig contains timeout and behavior settings for email analysis +type AnalysisConfig struct { + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string +} + +// DefaultConfig returns a configuration with sensible defaults +func DefaultConfig() *Config { + return &Config{ + DevProxy: "", + Bind: ":8080", + Database: DatabaseConfig{ + Type: "sqlite", + DSN: "happydeliver.db", + }, + Email: EmailConfig{ + Domain: "happydeliver.local", + TestAddressPrefix: "test-", + }, + Analysis: AnalysisConfig{ + DNSTimeout: 5 * time.Second, + HTTPTimeout: 10 * time.Second, + RBLs: []string{}, + }, + } +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = DefaultConfig() + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "happydeliver.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append( + configLocations, + path.Join(home, "happydeliver", "happydeliver.conf"), + path.Join(home, "happydomain", "happydeliver.conf"), + ) + } + + configLocations = append(configLocations, path.Join("etc", "happydeliver.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) { + log.Printf("Loading configuration from %s\n", filename) + err = parseFile(opts, filename) + if err != nil { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + return +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Email.Domain == "" { + return fmt.Errorf("email domain cannot be empty") + } + + if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil { + return fmt.Errorf("invalid email domain: %w", err) + } + + if c.Database.Type != "sqlite" && c.Database.Type != "postgres" { + return fmt.Errorf("unsupported database type: %s", c.Database.Type) + } + + if c.Database.DSN == "" { + return fmt.Errorf("database DSN cannot be empty") + } + + return nil +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + if len(value) == 0 { + return + } + + key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..9461632 --- /dev/null +++ b/internal/config/custom.go @@ -0,0 +1,45 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "strings" +) + +type StringArray struct { + Array *[]string +} + +func (i *StringArray) String() string { + if i.Array == nil { + return "" + } + + return fmt.Sprintf("%v", *i.Array) +} + +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, strings.Split(value, ",")...) + + return nil +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..cd4c344 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,42 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by HAPPYDELIVER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..ec28a58 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +} From 62bb85ebec823c2b2d4547ee828c86f6a719de28 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 15 Oct 2025 16:16:29 +0700 Subject: [PATCH 016/256] Glue things together --- README.md | 163 ++++++++++++++++++++++++++ cmd/happyDeliver/main.go | 105 +++++++++++++++-- go.mod | 24 ++-- go.sum | 49 +++++--- internal/api/handlers.go | 215 ++++++++++++++++++++++++++++++++++ internal/api/helpers.go | 4 + internal/receiver/receiver.go | 204 ++++++++++++++++++++++++++++++++ internal/storage/models.go | 73 ++++++++++++ internal/storage/storage.go | 163 ++++++++++++++++++++++++++ 9 files changed, 972 insertions(+), 28 deletions(-) create mode 100644 README.md create mode 100644 internal/api/handlers.go create mode 100644 internal/receiver/receiver.go create mode 100644 internal/storage/models.go create mode 100644 internal/storage/storage.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ea16aa --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# happyDeliver + +An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring. + +## Features + +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **REST API**: Full-featured API for creating tests and retrieving reports +- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails +- **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 + +### 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 Transport rule + +You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html). + +1. Append the following lines at the end of your `master.cf` file: + + ```diff + + + +# happyDeliver analyzer - receives emails matching transport_maps + +happydeliver unix - n n - - pipe + + flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient} + ``` + +2. Create the file `/etc/postfix/transport_happyDeliver` with the following content: + + ``` + # Transport map - route test emails to happyDeliver analyzer + # Pattern: test-@yourdomain.com -> happydeliver pipe + + /^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver: + ``` + +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_maps + ``` + + If your `transport_maps` option is not set, just append this line: + + ``` + transport_maps = pcre:/etc/postfix/transport_maps + ``` + + Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. + +#### Postfix Aliases + +You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`). + +Add the following line in your `/etc/postfix/aliases`: + +```diff ++test: "|/path/to/happyDeliver analyze" +``` + +Note that the recipient address has to be present in header. + +### 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 (MDA Mode) + +To process an email from an MTA pipe: + +```bash +cat email.eml | ./happyDeliver analyze +``` + +Or specify recipient explicitly: + +```bash +cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com +``` + +## 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 + +**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/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 63445d1..c837ca4 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -22,14 +22,25 @@ package main import ( + "flag" "fmt" + "io" "log" "os" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/receiver" + "git.happydns.org/happyDeliver/internal/storage" ) +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 +51,11 @@ func main() { switch command { case "server": - log.Println("Starting API server...") - // TODO: Start API server + runServer(cfg) case "analyze": - log.Println("Starting email analyzer...") - // TODO: Start email analyzer (LMTP/pipe mode) + runAnalyzer(cfg) case "version": - fmt.Println("0.1.0-dev") + fmt.Println(version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -54,6 +63,88 @@ func main() { } } +func runServer(cfg *config.Config) { + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + log.Fatalf("Failed to initialize storage: %v", err) + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // 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) + + // Start 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 { + log.Fatalf("Failed to start server: %v", err) + } +} + +func runAnalyzer(cfg *config.Config) { + // Parse command-line flags + fs := flag.NewFlagSet("analyze", flag.ExitOnError) + recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)") + fs.Parse(flag.Args()[1:]) + + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + log.Fatalf("Failed to initialize storage: %v", err) + } + defer store.Close() + + log.Printf("Email analyzer ready, reading from stdin...") + + // Read email from stdin + emailData, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("Failed to read email from stdin: %v", err) + } + + // If recipient not provided, try to extract from headers + var recipient string + if *recipientEmail != "" { + recipient = *recipientEmail + } else { + recipient, err = receiver.ExtractRecipientFromHeaders(emailData) + if err != nil { + log.Fatalf("Failed to extract recipient: %v", err) + } + log.Printf("Extracted recipient: %s", recipient) + } + + // Process the email + recv := receiver.NewEmailReceiver(store, cfg) + if err := recv.ProcessEmailBytes(emailData, recipient); err != nil { + log.Fatalf("Failed to process email: %v", err) + } + + log.Println("Email processed successfully") +} + func printUsage() { fmt.Println("\nCommand availables:") fmt.Println(" happyDeliver server - Start the API server") diff --git a/go.mod b/go.mod index 8a2e2d9..74c97cd 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,10 @@ require ( 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.45.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.0 ) require ( @@ -25,12 +28,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 @@ -48,12 +58,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..4b1490e 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,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 +100,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= @@ -143,6 +157,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 +177,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 +189,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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 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 +211,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 +257,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..af17112 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,215 @@ +// 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 + testID := uuid.New() + + // Generate test email address + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + testID.String(), + h.config.Email.Domain, + ) + + // Create test in database + test, err := h.storage.CreateTest(testID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to create test", + Details: stringPtr(err.Error()), + }) + return + } + + // Return response + c.JSON(http.StatusCreated, TestResponse{ + Id: test.ID, + Email: openapi_types.Email(email), + Status: TestResponseStatusPending, + Message: stringPtr("Send your test email to the given address"), + }) +} + +// GetTest retrieves test metadata +// (GET /test/{id}) +func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { + test, err := h.storage.GetTest(id) + if err != nil { + if err == storage.ErrNotFound { + c.JSON(http.StatusNotFound, Error{ + Error: "not_found", + Message: "Test not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve test", + Details: stringPtr(err.Error()), + }) + return + } + + // Convert storage status to API status + var apiStatus TestStatus + switch test.Status { + case storage.StatusPending: + apiStatus = TestStatusPending + case storage.StatusReceived: + apiStatus = TestStatusReceived + case storage.StatusAnalyzed: + apiStatus = TestStatusAnalyzed + case storage.StatusFailed: + apiStatus = TestStatusFailed + default: + apiStatus = TestStatusPending + } + + // Generate test email address + email := fmt.Sprintf("%s%s@%s", + h.config.Email.TestAddressPrefix, + test.ID.String(), + h.config.Email.Domain, + ) + + c.JSON(http.StatusOK, Test{ + Id: test.ID, + Email: openapi_types.Email(email), + Status: apiStatus, + CreatedAt: test.CreatedAt, + UpdatedAt: &test.UpdatedAt, + }) +} + +// 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 + dbStatus := StatusComponentsDatabaseUp + if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound { + 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/receiver/receiver.go b/internal/receiver/receiver.go new file mode 100644 index 0000000..55a03ec --- /dev/null +++ b/internal/receiver/receiver.go @@ -0,0 +1,204 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package receiver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "regexp" + "strings" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/analyzer" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// EmailReceiver handles incoming emails from the MTA +type EmailReceiver struct { + storage storage.Storage + config *config.Config +} + +// NewEmailReceiver creates a new email receiver +func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver { + return &EmailReceiver{ + storage: store, + config: 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) + + // Verify test exists and is in pending status + test, err := r.storage.GetTest(testID) + if err != nil { + return fmt.Errorf("test not found: %w", err) + } + + if test.Status != storage.StatusPending { + return fmt.Errorf("test is not in pending status (current: %s)", test.Status) + } + + // Update test status to received + if err := r.storage.UpdateTestStatus(testID, storage.StatusReceived); err != nil { + return fmt.Errorf("failed to update test status: %w", err) + } + + log.Printf("Analyzing email for test %s", testID) + + // Parse the email + emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail)) + if err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + return fmt.Errorf("failed to parse email: %w", err) + } + + // Create report generator with configuration + generator := analyzer.NewReportGenerator( + r.config.Analysis.DNSTimeout, + r.config.Analysis.HTTPTimeout, + r.config.Analysis.RBLs, + ) + + // Analyze the email + results := generator.AnalyzeEmail(emailMsg) + + // Generate the report + report := generator.GenerateReport(testID, results) + + log.Printf("Analysis complete. Score: %.2f/10", report.Score) + + // Marshal report to JSON + reportJSON, err := json.Marshal(report) + if err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + return fmt.Errorf("failed to marshal report: %w", err) + } + + // Store the report + if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { + // Update test status to failed + if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { + log.Printf("Failed to update test status to failed: %v", updateErr) + } + 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..546bf2f --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package storage + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// TestStatus represents the status of a deliverability test +type TestStatus string + +const ( + StatusPending TestStatus = "pending" + StatusReceived TestStatus = "received" + StatusAnalyzed TestStatus = "analyzed" + StatusFailed TestStatus = "failed" +) + +// Test represents a deliverability test instance +type Test struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Status TestStatus `gorm:"type:varchar(20);not null;index"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Report *Report `gorm:"foreignKey:TestID;constraint:OnDelete:CASCADE"` +} + +// BeforeCreate is a GORM hook that generates a UUID before creating a test +func (t *Test) BeforeCreate(tx *gorm.DB) error { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + return nil +} + +// 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"` + 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..ff06edc --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,163 @@ +// 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" + + "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 { + // Test operations + CreateTest(id uuid.UUID) (*Test, error) + GetTest(id uuid.UUID) (*Test, error) + UpdateTestStatus(id uuid.UUID, status TestStatus) error + + // Report operations + CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) + GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err 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(&Test{}, &Report{}); err != nil { + return nil, fmt.Errorf("failed to migrate database schema: %w", err) + } + + return &DBStorage{db: db}, nil +} + +// CreateTest creates a new test with pending status +func (s *DBStorage) CreateTest(id uuid.UUID) (*Test, error) { + test := &Test{ + ID: id, + Status: StatusPending, + } + + if err := s.db.Create(test).Error; err != nil { + return nil, fmt.Errorf("failed to create test: %w", err) + } + + return test, nil +} + +// GetTest retrieves a test by ID +func (s *DBStorage) GetTest(id uuid.UUID) (*Test, error) { + var test Test + if err := s.db.First(&test, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get test: %w", err) + } + return &test, nil +} + +// UpdateTestStatus updates the status of a test +func (s *DBStorage) UpdateTestStatus(id uuid.UUID, status TestStatus) error { + result := s.db.Model(&Test{}).Where("id = ?", id).Update("status", status) + if result.Error != nil { + return fmt.Errorf("failed to update test status: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return 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) + } + + // Update test status to analyzed + if err := s.UpdateTestStatus(testID, StatusAnalyzed); err != nil { + return nil, fmt.Errorf("failed to update test status: %w", err) + } + + return dbReport, 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 +} + +// Close closes the database connection +func (s *DBStorage) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} From 3d823dedd89ab587edc7b0224d03828cf2cfc42b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 16 Oct 2025 17:47:37 +0700 Subject: [PATCH 017/256] Add AIO Dockerfile --- .dockerignore | 27 +++++ .gitignore | 3 + Dockerfile | 87 +++++++++++++++ README.md | 65 +++++++++++- docker-compose.yml | 38 +++++++ docker/README.md | 164 +++++++++++++++++++++++++++++ docker/entrypoint.sh | 66 ++++++++++++ docker/opendkim/opendkim.conf | 39 +++++++ docker/opendmarc/opendmarc.conf | 41 ++++++++ docker/postfix/aliases | 10 ++ docker/postfix/main.cf | 41 ++++++++ docker/postfix/master.cf | 87 +++++++++++++++ docker/postfix/transport_maps | 4 + docker/spamassassin/local.cf | 50 +++++++++ docker/supervisor/supervisord.conf | 76 +++++++++++++ 15 files changed, 793 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/README.md create mode 100644 docker/entrypoint.sh create mode 100644 docker/opendkim/opendkim.conf create mode 100644 docker/opendmarc/opendmarc.conf create mode 100644 docker/postfix/aliases create mode 100644 docker/postfix/main.cf create mode 100644 docker/postfix/master.cf create mode 100644 docker/postfix/transport_maps create mode 100644 docker/spamassassin/local.cf create mode 100644 docker/supervisor/supervisord.conf 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/.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..e797eff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,87 @@ +# Multi-stage Dockerfile for happyDeliver with integrated MTA +# Stage 1: 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 . . + +# Build the application +RUN go generate ./... && \ + CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver + +# Stage 2: 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 index 6ea16aa..93c1c43 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,62 @@ An open-source email deliverability testing platform that analyzes test emails a ## Quick Start -### 1. Build +### 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 @@ -28,7 +83,7 @@ go build -o happyDeliver ./cmd/happyDeliver The server will start on `http://localhost:8080` by default. -### 3. Integrate with your existing e-mail setup +#### 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. @@ -84,7 +139,7 @@ Add the following line in your `/etc/postfix/aliases`: Note that the recipient address has to be present in header. -### 4. Create a Test +#### 4. Create a Test ```bash curl -X POST http://localhost:8080/api/test @@ -100,11 +155,11 @@ Response: } ``` -### 5. Send Test Email +#### 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 +#### 6. Get Report ```bash curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 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..a12b13f --- /dev/null +++ b/docker/postfix/master.cf @@ -0,0 +1,87 @@ +# 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} + +# happyDeliver analyzer - receives emails matching transport_maps +happydeliver unix - n n - - pipe + flags=DRXhu user=happydeliver argv=/usr/local/bin/happyDeliver analyze -config /etc/happydeliver/config.yaml -recipient ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps new file mode 100644 index 0000000..c12f4cc --- /dev/null +++ b/docker/postfix/transport_maps @@ -0,0 +1,4 @@ +# Transport map - route test emails to happyDeliver analyzer +# Pattern: test-@domain.com -> happydeliver pipe + +/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ happydeliver: 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" From 682ca6bb20553b7e2494da19ea3137ed8f50aed9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 17:18:37 +0700 Subject: [PATCH 018/256] Web UI setup --- Dockerfile | 17 +- cmd/happyDeliver/main.go | 2 + web/.gitignore | 26 + web/.npmrc | 1 + web/.prettierignore | 9 + web/.prettierrc | 13 + web/assets.go | 43 + web/eslint.config.js | 41 + web/openapi-ts.config.ts | 12 + web/package-lock.json | 5532 ++++++++++++++++++++++++++++++++++++++ web/package.json | 43 + web/routes.go | 171 ++ web/src/app.d.ts | 13 + web/src/app.html | 11 + web/src/lib/hey-api.ts | 30 + web/src/lib/index.ts | 1 + web/svelte.config.js | 19 + web/tsconfig.json | 19 + web/vite.config.ts | 25 + 19 files changed, 6026 insertions(+), 2 deletions(-) create mode 100644 web/.gitignore create mode 100644 web/.npmrc create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc create mode 100644 web/assets.go create mode 100644 web/eslint.config.js create mode 100644 web/openapi-ts.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/routes.go create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/lib/hey-api.ts create mode 100644 web/src/lib/index.ts create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/Dockerfile b/Dockerfile index e797eff..36d7d33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,17 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA -# Stage 1: Build the Go application +# 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 @@ -13,12 +25,13 @@ 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 2: Runtime image with Postfix and all filters +# Stage 3: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index c837ca4..da8ccb1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -34,6 +34,7 @@ import ( "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/receiver" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/web" ) const version = "0.1.0-dev" @@ -89,6 +90,7 @@ func runServer(cfg *config.Config) { // Register API routes apiGroup := router.Group("/api") api.RegisterHandlers(apiGroup, handler) + web.DeclareRoutes(cfg, router) // Start server log.Printf("Starting API server on %s", cfg.Bind) 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..b1719e9 --- /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: "./src/lib/hey-api.ts", + }, + ], +}); diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3129b3f --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5532 @@ +{ + "name": "happyDeliver", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "happyDeliver", + "version": "0.1.0", + "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.80.0", + "@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/@bufbuild/protobuf": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", + "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "optional": true, + "peer": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "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.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "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.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "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/json-schema-ref-parser": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", + "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.0.tgz", + "integrity": "sha512-sX0TFKCvwMyh10C1mmqYR2TBaHla//72kocuPpRM5ya38LqRaqkMW9A0hjcrZTrzFtjYtz2Pdr3in+JrsM3TLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/json-schema-ref-parser": "1.0.6", + "ansi-colors": "4.1.3", + "c12": "2.0.1", + "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.18.0 || ^20.9.0 || >=22.10.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/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "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.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "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.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", + "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", + "dev": true, + "license": "MIT", + "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", + "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", + "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", + "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/runner/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/@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/snapshot/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/@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", + "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/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11", + "optional": true, + "peer": true + }, + "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": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.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/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "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.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "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/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/devalue": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", + "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "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.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.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", + "@types/json-schema": "^7.0.15", + "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.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", + "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "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.3.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/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/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/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": "1.2.5", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", + "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", + "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.5.4", + "pathe": "^2.0.3", + "tar": "^6.2.1" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/giget/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/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/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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/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/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/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/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-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", + "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "tinyexec": "^0.3.2", + "ufo": "^1.5.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/nypm/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/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "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": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "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": "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/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/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/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", + "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", + "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.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "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.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", + "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.93.2", + "sass-embedded-android-arm": "1.93.2", + "sass-embedded-android-arm64": "1.93.2", + "sass-embedded-android-riscv64": "1.93.2", + "sass-embedded-android-x64": "1.93.2", + "sass-embedded-darwin-arm64": "1.93.2", + "sass-embedded-darwin-x64": "1.93.2", + "sass-embedded-linux-arm": "1.93.2", + "sass-embedded-linux-arm64": "1.93.2", + "sass-embedded-linux-musl-arm": "1.93.2", + "sass-embedded-linux-musl-arm64": "1.93.2", + "sass-embedded-linux-musl-riscv64": "1.93.2", + "sass-embedded-linux-musl-x64": "1.93.2", + "sass-embedded-linux-riscv64": "1.93.2", + "sass-embedded-linux-x64": "1.93.2", + "sass-embedded-unknown-all": "1.93.2", + "sass-embedded-win32-arm64": "1.93.2", + "sass-embedded-win32-x64": "1.93.2" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", + "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", + "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", + "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", + "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", + "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", + "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", + "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", + "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", + "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", + "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", + "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", + "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", + "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", + "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", + "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", + "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "peer": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", + "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", + "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "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.40.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", + "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "dev": true, + "license": "MIT", + "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-check/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-check/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", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", + "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "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" + }, + "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/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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": "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/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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true, + "peer": true + }, + "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", + "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/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "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/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vite": { + "version": "7.1.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", + "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "dev": true, + "license": "MIT", + "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/vite-node/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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "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/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/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/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/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "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..1687702 --- /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.80.0", + "@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..22fd15c --- /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 != "" && 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.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/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/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}"], + }, + }, + ], + }, +}); From 4b9733531e191c8639eea766c97c3611f22b95f5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 17 Oct 2025 18:45:53 +0700 Subject: [PATCH 019/256] Implement web ui --- web/src/app.css | 152 ++++++++++++ web/src/lib/components/CheckCard.svelte | 74 ++++++ .../lib/components/EmailAddressDisplay.svelte | 46 ++++ web/src/lib/components/FeatureCard.svelte | 33 +++ web/src/lib/components/HowItWorksStep.svelte | 17 ++ web/src/lib/components/PendingState.svelte | 115 ++++++++++ web/src/lib/components/ScoreCard.svelte | 71 ++++++ .../lib/components/SpamAssassinCard.svelte | 65 ++++++ web/src/lib/components/index.ts | 8 + web/src/routes/+error.svelte | 150 ++++++++++++ web/src/routes/+layout.svelte | 51 +++++ web/src/routes/+page.svelte | 216 ++++++++++++++++++ web/src/routes/test/[test]/+page.svelte | 143 ++++++++++++ 13 files changed, 1141 insertions(+) create mode 100644 web/src/app.css create mode 100644 web/src/lib/components/CheckCard.svelte create mode 100644 web/src/lib/components/EmailAddressDisplay.svelte create mode 100644 web/src/lib/components/FeatureCard.svelte create mode 100644 web/src/lib/components/HowItWorksStep.svelte create mode 100644 web/src/lib/components/PendingState.svelte create mode 100644 web/src/lib/components/ScoreCard.svelte create mode 100644 web/src/lib/components/SpamAssassinCard.svelte create mode 100644 web/src/lib/components/index.ts create mode 100644 web/src/routes/+error.svelte create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/test/[test]/+page.svelte 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/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..a5075e8 --- /dev/null +++ b/web/src/lib/components/PendingState.svelte @@ -0,0 +1,115 @@ + + +
+
+
+
+
+ +
+ +

Waiting for Your Email

+

Send your test email to the address below:

+ +
+ +
+ + + + {#if test.status === "received"} + + {/if} + +
+
+ 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/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..6a62c0d --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,216 @@ + + + + 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} +
+ + From 18c2f9511267c97eec106587d2d2d4072703c9d8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:41:07 +0700 Subject: [PATCH 020/256] Refactor main.go --- cmd/happyDeliver/main.go | 105 +++---------------------- internal/analyzer/analyzer.go | 87 +++++++++++++++++++++ internal/app/cli_analyzer.go | 143 ++++++++++++++++++++++++++++++++++ internal/app/server.go | 74 ++++++++++++++++++ internal/receiver/receiver.go | 34 +++----- 5 files changed, 325 insertions(+), 118 deletions(-) create mode 100644 internal/analyzer/analyzer.go create mode 100644 internal/app/cli_analyzer.go create mode 100644 internal/app/server.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index da8ccb1..01d99f1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -24,17 +24,11 @@ package main import ( "flag" "fmt" - "io" "log" "os" - "github.com/gin-gonic/gin" - - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/receiver" - "git.happydns.org/happyDeliver/internal/storage" - "git.happydns.org/happyDeliver/web" ) const version = "0.1.0-dev" @@ -52,9 +46,13 @@ func main() { switch command { case "server": - runServer(cfg) + if err := app.RunServer(cfg); err != nil { + log.Fatalf("Server error: %v", err) + } case "analyze": - runAnalyzer(cfg) + if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { + log.Fatalf("Analyzer error: %v", err) + } case "version": fmt.Println(version) default: @@ -64,94 +62,11 @@ func main() { } } -func runServer(cfg *config.Config) { - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Connected to %s database", cfg.Database.Type) - - // 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 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 { - log.Fatalf("Failed to start server: %v", err) - } -} - -func runAnalyzer(cfg *config.Config) { - // Parse command-line flags - fs := flag.NewFlagSet("analyze", flag.ExitOnError) - recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)") - fs.Parse(flag.Args()[1:]) - - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Email analyzer ready, reading from stdin...") - - // Read email from stdin - emailData, err := io.ReadAll(os.Stdin) - if err != nil { - log.Fatalf("Failed to read email from stdin: %v", err) - } - - // If recipient not provided, try to extract from headers - var recipient string - if *recipientEmail != "" { - recipient = *recipientEmail - } else { - recipient, err = receiver.ExtractRecipientFromHeaders(emailData) - if err != nil { - log.Fatalf("Failed to extract recipient: %v", err) - } - log.Printf("Extracted recipient: %s", recipient) - } - - // Process the email - recv := receiver.NewEmailReceiver(store, cfg) - if err := recv.ProcessEmailBytes(emailData, recipient); err != nil { - log.Fatalf("Failed to process email: %v", err) - } - - log.Println("Email processed successfully") -} - 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/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..3588280 --- /dev/null +++ b/internal/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/app/cli_analyzer.go b/internal/app/cli_analyzer.go new file mode 100644 index 0000000..87a4e0a --- /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/analyzer" + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// 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..9c7d28b --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,74 @@ +// 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 ( + "log" + "os" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/web" +) + +// RunServer starts the API server 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) + + // 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/receiver/receiver.go b/internal/receiver/receiver.go index 55a03ec..325ef31 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,7 +22,6 @@ package receiver import ( - "bytes" "encoding/json" "fmt" "io" @@ -39,15 +38,17 @@ import ( // EmailReceiver handles incoming emails from the MTA type EmailReceiver struct { - storage storage.Storage - config *config.Config + 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, + storage: store, + config: cfg, + analyzer: analyzer.NewEmailAnalyzer(cfg), } } @@ -92,33 +93,20 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analyzing email for test %s", testID) - // Parse the email - emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail)) + // Analyze the email using the shared analyzer + result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { log.Printf("Failed to update test status to failed: %v", updateErr) } - return fmt.Errorf("failed to parse email: %w", err) + return fmt.Errorf("failed to analyze email: %w", err) } - // Create report generator with configuration - generator := analyzer.NewReportGenerator( - r.config.Analysis.DNSTimeout, - r.config.Analysis.HTTPTimeout, - r.config.Analysis.RBLs, - ) - - // Analyze the email - results := generator.AnalyzeEmail(emailMsg) - - // Generate the report - report := generator.GenerateReport(testID, results) - - log.Printf("Analysis complete. Score: %.2f/10", report.Score) + log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) // Marshal report to JSON - reportJSON, err := json.Marshal(report) + reportJSON, err := json.Marshal(result.Report) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { From 3867fa36a2b31a09ade4fa05a21ee07cc6fc2b0e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:41:28 +0700 Subject: [PATCH 021/256] Add LMTP server --- README.md | 49 ++++++------ docker/postfix/master.cf | 4 - docker/postfix/transport_maps | 6 +- go.mod | 2 + go.sum | 4 + internal/app/server.go | 10 ++- internal/config/cli.go | 1 + internal/config/config.go | 2 + internal/lmtp/server.go | 144 ++++++++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 35 deletions(-) create mode 100644 internal/lmtp/server.go diff --git a/README.md b/README.md index 93c1c43..c76e248 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a - **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports -- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails +- **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 @@ -90,54 +90,47 @@ happyDeliver will not perform thoses checks, it relies instead on standard softw Choose one of the following way to integrate happyDeliver in your existing setup: -#### Postfix Transport rule +#### Postfix LMTP Transport -You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html). +You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP. -1. Append the following lines at the end of your `master.cf` file: +1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`): - ```diff - + - +# happyDeliver analyzer - receives emails matching transport_maps - +happydeliver unix - n n - - pipe - + flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient} + ```bash + ./happyDeliver server ``` -2. Create the file `/etc/postfix/transport_happyDeliver` with the following content: + 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 analyzer - # Pattern: test-@yourdomain.com -> happydeliver pipe + # Transport map - route test emails to happyDeliver LMTP server + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver: + /^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_maps + +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_maps + transport_maps = pcre:/etc/postfix/transport_happydeliver ``` Note: to use the `pcre:` type, you need to have `postfix-pcre` installed. -#### Postfix Aliases +4. Reload Postfix configuration: -You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`). - -Add the following line in your `/etc/postfix/aliases`: - -```diff -+test: "|/path/to/happyDeliver analyze" -``` - -Note that the recipient address has to be present in header. + ```bash + postfix reload + ``` #### 4. Create a Test @@ -175,9 +168,9 @@ curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000 | `/api/report/{id}/raw` | GET | Get raw annotated email | | `/api/status` | GET | Service health and status | -## Email Analyzer (MDA Mode) +## Email Analyzer (CLI Mode) -To process an email from an MTA pipe: +For manual testing or debugging, you can analyze emails from the command line: ```bash cat email.eml | ./happyDeliver analyze @@ -189,6 +182,8 @@ Or specify recipient explicitly: 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: diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index a12b13f..92976a4 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -81,7 +81,3 @@ policy-spf unix - n n - 0 spawn # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} - -# happyDeliver analyzer - receives emails matching transport_maps -happydeliver unix - n n - - pipe - flags=DRXhu user=happydeliver argv=/usr/local/bin/happyDeliver analyze -config /etc/happydeliver/config.yaml -recipient ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index c12f4cc..49fdb98 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ -# Transport map - route test emails to happyDeliver analyzer -# Pattern: test-@domain.com -> happydeliver pipe +# 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}@.*$/ happydeliver: +/^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/go.mod b/go.mod index 74c97cd..ce87ef6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 @@ -19,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 diff --git a/go.sum b/go.sum index 4b1490e..cf49874 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= diff --git a/internal/app/server.go b/internal/app/server.go index 9c7d28b..8db4b59 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -29,11 +29,12 @@ import ( "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 server +// RunServer starts the API server and LMTP server func RunServer(cfg *config.Config) error { if err := cfg.Validate(); err != nil { return err @@ -48,6 +49,13 @@ func RunServer(cfg *config.Config) error { log.Printf("Connected to %s database", cfg.Database.Type) + // 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) diff --git a/internal/config/cli.go b/internal/config/cli.go index eee2c4c..cfd5908 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -33,6 +33,7 @@ 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)") diff --git a/internal/config/config.go b/internal/config/config.go index a866fbd..3304a8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ type DatabaseConfig struct { type EmailConfig struct { Domain string TestAddressPrefix string + LMTPAddr string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -73,6 +74,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 +} From 20f5b37e5ee6f38824f7e77ed438c3e73e6975e1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 11:56:22 +0700 Subject: [PATCH 022/256] Create test on email arrival --- api/openapi.yaml | 27 ++++------ internal/api/handlers.go | 54 +++++-------------- internal/receiver/receiver.go | 28 +++------- internal/storage/models.go | 33 ++---------- internal/storage/storage.go | 60 ++++------------------ web/src/lib/components/PendingState.svelte | 7 --- 6 files changed, 43 insertions(+), 166 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f027f1a..b278b6c 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: @@ -154,7 +154,6 @@ components: - id - email - status - - created_at properties: id: type: string @@ -168,17 +167,9 @@ 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 - format: date-time - description: Test creation timestamp - updated_at: - type: string - format: date-time - description: Last update timestamp TestResponse: type: object diff --git a/internal/api/handlers.go b/internal/api/handlers.go index af17112..b66db2d 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -53,7 +53,7 @@ func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { // CreateTest creates a new deliverability test // (POST /test) func (h *APIHandler) CreateTest(c *gin.Context) { - // Generate a unique test ID + // Generate a unique test ID (no database record created) testID := uuid.New() // Generate test email address @@ -63,20 +63,9 @@ func (h *APIHandler) CreateTest(c *gin.Context) { h.config.Email.Domain, ) - // Create test in database - test, err := h.storage.CreateTest(testID) - if err != nil { - c.JSON(http.StatusInternalServerError, Error{ - Error: "internal_error", - Message: "Failed to create test", - Details: stringPtr(err.Error()), - }) - return - } - // Return response c.JSON(http.StatusCreated, TestResponse{ - Id: test.ID, + Id: testID, Email: openapi_types.Email(email), Status: TestResponseStatusPending, Message: stringPtr("Send your test email to the given address"), @@ -86,51 +75,36 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // GetTest retrieves test metadata // (GET /test/{id}) func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { - test, err := h.storage.GetTest(id) + // Check if a report exists for this test ID + reportExists, err := h.storage.ReportExists(id) if err != nil { - if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ - Error: "not_found", - Message: "Test not found", - }) - return - } c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", - Message: "Failed to retrieve test", + Message: "Failed to check test status", Details: stringPtr(err.Error()), }) return } - // Convert storage status to API status + // Determine status based on report existence var apiStatus TestStatus - switch test.Status { - case storage.StatusPending: - apiStatus = TestStatusPending - case storage.StatusReceived: - apiStatus = TestStatusReceived - case storage.StatusAnalyzed: + if reportExists { apiStatus = TestStatusAnalyzed - case storage.StatusFailed: - apiStatus = TestStatusFailed - default: + } else { apiStatus = TestStatusPending } // Generate test email address email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - test.ID.String(), + id.String(), h.config.Email.Domain, ) c.JSON(http.StatusOK, Test{ - Id: test.ID, - Email: openapi_types.Email(email), - Status: apiStatus, - CreatedAt: test.CreatedAt, - UpdatedAt: &test.UpdatedAt, + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, }) } @@ -187,9 +161,9 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // Calculate uptime uptime := int(time.Since(h.startTime).Seconds()) - // Check database connectivity + // Check database connectivity by trying to check if a report exists dbStatus := StatusComponentsDatabaseUp - if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound { + if _, err := h.storage.ReportExists(uuid.New()); err != nil { dbStatus = StatusComponentsDatabaseDown } diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 325ef31..db1c2ea 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -76,19 +76,15 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Extracted test ID: %s", testID) - // Verify test exists and is in pending status - test, err := r.storage.GetTest(testID) + // Check if a report already exists for this test ID + reportExists, err := r.storage.ReportExists(testID) if err != nil { - return fmt.Errorf("test not found: %w", err) + return fmt.Errorf("failed to check report existence: %w", err) } - if test.Status != storage.StatusPending { - return fmt.Errorf("test is not in pending status (current: %s)", test.Status) - } - - // Update test status to received - if err := r.storage.UpdateTestStatus(testID, storage.StatusReceived); err != nil { - return fmt.Errorf("failed to update test status: %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) @@ -96,10 +92,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string // Analyze the email using the shared analyzer result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) if err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to analyze email: %w", err) } @@ -108,19 +100,11 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to marshal report: %w", err) } // Store the report if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { - // Update test status to failed - if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { - log.Printf("Failed to update test status to failed: %v", updateErr) - } return fmt.Errorf("failed to store report: %w", err) } diff --git a/internal/storage/models.go b/internal/storage/models.go index 546bf2f..dbb3daa 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -28,39 +28,12 @@ import ( "gorm.io/gorm" ) -// TestStatus represents the status of a deliverability test -type TestStatus string - -const ( - StatusPending TestStatus = "pending" - StatusReceived TestStatus = "received" - StatusAnalyzed TestStatus = "analyzed" - StatusFailed TestStatus = "failed" -) - -// Test represents a deliverability test instance -type Test struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey"` - Status TestStatus `gorm:"type:varchar(20);not null;index"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - Report *Report `gorm:"foreignKey:TestID;constraint:OnDelete:CASCADE"` -} - -// BeforeCreate is a GORM hook that generates a UUID before creating a test -func (t *Test) BeforeCreate(tx *gorm.DB) error { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - return nil -} - // 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"` - RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers - ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data + 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"` } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ff06edc..7550463 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -38,14 +38,10 @@ var ( // Storage interface defines operations for persisting and retrieving data type Storage interface { - // Test operations - CreateTest(id uuid.UUID) (*Test, error) - GetTest(id uuid.UUID) (*Test, error) - UpdateTestStatus(id uuid.UUID, status TestStatus) error - // 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) // Close closes the database connection Close() error @@ -75,51 +71,13 @@ func NewStorage(dbType, dsn string) (Storage, error) { } // Auto-migrate the schema - if err := db.AutoMigrate(&Test{}, &Report{}); err != nil { + if err := db.AutoMigrate(&Report{}); err != nil { return nil, fmt.Errorf("failed to migrate database schema: %w", err) } return &DBStorage{db: db}, nil } -// CreateTest creates a new test with pending status -func (s *DBStorage) CreateTest(id uuid.UUID) (*Test, error) { - test := &Test{ - ID: id, - Status: StatusPending, - } - - if err := s.db.Create(test).Error; err != nil { - return nil, fmt.Errorf("failed to create test: %w", err) - } - - return test, nil -} - -// GetTest retrieves a test by ID -func (s *DBStorage) GetTest(id uuid.UUID) (*Test, error) { - var test Test - if err := s.db.First(&test, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("failed to get test: %w", err) - } - return &test, nil -} - -// UpdateTestStatus updates the status of a test -func (s *DBStorage) UpdateTestStatus(id uuid.UUID, status TestStatus) error { - result := s.db.Model(&Test{}).Where("id = ?", id).Update("status", status) - if result.Error != nil { - return fmt.Errorf("failed to update test status: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrNotFound - } - return nil -} - // CreateReport creates a new report for a test func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) { dbReport := &Report{ @@ -132,14 +90,18 @@ func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON [ return nil, fmt.Errorf("failed to create report: %w", err) } - // Update test status to analyzed - if err := s.UpdateTestStatus(testID, StatusAnalyzed); err != nil { - return nil, fmt.Errorf("failed to update test status: %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 diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index a5075e8..ab9a6f8 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -30,13 +30,6 @@ transactional emails, etc.) for the most accurate results.
- {#if test.status === "received"} - - {/if} -
Checking for email every 3 seconds... From 16a0f3a15884ef6dc2a34dc05d3d7320eb25b4ac Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 03:07:38 +0000 Subject: [PATCH 023/256] Add renovate.json --- renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 renovate.json 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" + ] +} From 079dc6a813c3e754224362de1b1d59d0a7380ec6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 05:10:18 +0000 Subject: [PATCH 024/256] Update module github.com/quic-go/quic-go to v0.54.1 [SECURITY] --- go.mod | 5 ++--- go.sum | 10 ++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index ce87ef6..e51b1d5 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ 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 @@ -15,13 +14,13 @@ require ( ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/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/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -52,7 +51,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 diff --git a/go.sum b/go.sum index cf49874..939e263 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -88,7 +84,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -144,8 +139,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= @@ -154,7 +149,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 6565c6fda43ba86548e4c2ea5bd72abe415454a4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 05:10:49 +0000 Subject: [PATCH 025/256] Update dependency @hey-api/openapi-ts to v0.85.2 --- web/openapi-ts.config.ts | 2 +- web/package-lock.json | 10840 ++++++++++++++++++------------------- web/package.json | 2 +- 3 files changed, 5312 insertions(+), 5532 deletions(-) diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts index b1719e9..dfe34de 100644 --- a/web/openapi-ts.config.ts +++ b/web/openapi-ts.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [ { name: "@hey-api/client-fetch", - runtimeConfigPath: "./src/lib/hey-api.ts", + runtimeConfigPath: "$lib/hey-api.ts", }, ], }); diff --git a/web/package-lock.json b/web/package-lock.json index 3129b3f..714f1c0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,5532 +1,5312 @@ { - "name": "happyDeliver", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "happyDeliver", - "version": "0.1.0", - "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.80.0", - "@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/@bufbuild/protobuf": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", - "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", - "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)", - "optional": true, - "peer": true - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", - "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.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "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.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", - "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.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", - "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.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "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/json-schema-ref-parser": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", - "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", - "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.80.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.0.tgz", - "integrity": "sha512-sX0TFKCvwMyh10C1mmqYR2TBaHla//72kocuPpRM5ya38LqRaqkMW9A0hjcrZTrzFtjYtz2Pdr3in+JrsM3TLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/json-schema-ref-parser": "1.0.6", - "ansi-colors": "4.1.3", - "c12": "2.0.1", - "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.18.0 || ^20.9.0 || >=22.10.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/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "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.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", - "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.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", - "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", - "dev": true, - "license": "MIT", - "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", - "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", - "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", - "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/runner/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/@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/snapshot/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/@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", - "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/buffer-builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "dev": true, - "license": "MIT/X11", - "optional": true, - "peer": true - }, - "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": "2.0.1", - "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", - "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.1", - "confbox": "^0.1.7", - "defu": "^6.1.4", - "dotenv": "^16.4.5", - "giget": "^1.2.3", - "jiti": "^2.3.0", - "mlly": "^1.7.1", - "ohash": "^1.1.4", - "pathe": "^1.1.2", - "perfect-debounce": "^1.0.0", - "pkg-types": "^1.2.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/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "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/colorjs.io": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", - "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", - "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.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "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/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/devalue": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", - "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "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.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.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", - "@types/json-schema": "^7.0.15", - "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.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", - "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", - "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.3.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/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/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/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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": "1.2.5", - "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", - "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", - "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.5.4", - "pathe": "^2.0.3", - "tar": "^6.2.1" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/giget/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/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/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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/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/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/mlly/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/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-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/nypm": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", - "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "tinyexec": "^0.3.2", - "ufo": "^1.5.4" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, - "node_modules/nypm/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/ohash": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", - "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", - "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": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "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": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "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": "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/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/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/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", - "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", - "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.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "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.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", - "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/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "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/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-embedded": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", - "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@bufbuild/protobuf": "^2.5.0", - "buffer-builder": "^0.2.0", - "colorjs.io": "^0.5.0", - "immutable": "^5.0.2", - "rxjs": "^7.4.0", - "supports-color": "^8.1.1", - "sync-child-process": "^1.0.2", - "varint": "^6.0.0" - }, - "bin": { - "sass": "dist/bin/sass.js" - }, - "engines": { - "node": ">=16.0.0" - }, - "optionalDependencies": { - "sass-embedded-all-unknown": "1.93.2", - "sass-embedded-android-arm": "1.93.2", - "sass-embedded-android-arm64": "1.93.2", - "sass-embedded-android-riscv64": "1.93.2", - "sass-embedded-android-x64": "1.93.2", - "sass-embedded-darwin-arm64": "1.93.2", - "sass-embedded-darwin-x64": "1.93.2", - "sass-embedded-linux-arm": "1.93.2", - "sass-embedded-linux-arm64": "1.93.2", - "sass-embedded-linux-musl-arm": "1.93.2", - "sass-embedded-linux-musl-arm64": "1.93.2", - "sass-embedded-linux-musl-riscv64": "1.93.2", - "sass-embedded-linux-musl-x64": "1.93.2", - "sass-embedded-linux-riscv64": "1.93.2", - "sass-embedded-linux-x64": "1.93.2", - "sass-embedded-unknown-all": "1.93.2", - "sass-embedded-win32-arm64": "1.93.2", - "sass-embedded-win32-x64": "1.93.2" - } - }, - "node_modules/sass-embedded-all-unknown": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", - "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", - "cpu": [ - "!arm", - "!arm64", - "!riscv64", - "!x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-android-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", - "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", - "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", - "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", - "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", - "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", - "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", - "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", - "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", - "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", - "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", - "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", - "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", - "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", - "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-unknown-all": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", - "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "!android", - "!darwin", - "!linux", - "!win32" - ], - "peer": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", - "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", - "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "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.40.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", - "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", - "dev": true, - "license": "MIT", - "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-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/svelte-check/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", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/svelte-eslint-parser": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", - "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", - "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" - }, - "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/sync-child-process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", - "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "sync-message-port": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "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": "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/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/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "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/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, - "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", - "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/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, - "license": "MIT" - }, - "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/varint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/vite": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", - "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", - "dev": true, - "license": "MIT", - "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/vite-node/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/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "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/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/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/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/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "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" - } - } + "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/@bufbuild/protobuf": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", + "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "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.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "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.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "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.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "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/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "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.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "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.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.47.1.tgz", + "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", + "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/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11", + "optional": true + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "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/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "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/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/devalue": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", + "integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==", + "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.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.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", + "@types/json-schema": "^7.0.15", + "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.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", + "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "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.3.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/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/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "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/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-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "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/nypm/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/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": "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/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.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "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.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", + "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.93.2", + "sass-embedded-android-arm": "1.93.2", + "sass-embedded-android-arm64": "1.93.2", + "sass-embedded-android-riscv64": "1.93.2", + "sass-embedded-android-x64": "1.93.2", + "sass-embedded-darwin-arm64": "1.93.2", + "sass-embedded-darwin-x64": "1.93.2", + "sass-embedded-linux-arm": "1.93.2", + "sass-embedded-linux-arm64": "1.93.2", + "sass-embedded-linux-musl-arm": "1.93.2", + "sass-embedded-linux-musl-arm64": "1.93.2", + "sass-embedded-linux-musl-riscv64": "1.93.2", + "sass-embedded-linux-musl-x64": "1.93.2", + "sass-embedded-linux-riscv64": "1.93.2", + "sass-embedded-linux-x64": "1.93.2", + "sass-embedded-unknown-all": "1.93.2", + "sass-embedded-win32-arm64": "1.93.2", + "sass-embedded-win32-x64": "1.93.2" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", + "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", + "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", + "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", + "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", + "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", + "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", + "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", + "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", + "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", + "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", + "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", + "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", + "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", + "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", + "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", + "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.93.2" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", + "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", + "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "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.40.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", + "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "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-check/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", + "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "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" + }, + "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/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "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/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/vite": { + "version": "7.1.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", + "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", + "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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "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/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/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 index 1687702..d0a2578 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.80.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", From 43047847967ad3b954daffc0e4e6e35e71f2e608 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 12:25:27 +0700 Subject: [PATCH 026/256] Add an auto-cleanup worker --- internal/app/cleanup.go | 108 ++++++++++++++++++++++++++++++++++++ internal/app/server.go | 7 +++ internal/config/cli.go | 1 + internal/config/config.go | 16 +++--- internal/storage/storage.go | 11 ++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 internal/app/cleanup.go 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/server.go b/internal/app/server.go index 8db4b59..332516b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -22,6 +22,7 @@ package app import ( + "context" "log" "os" @@ -49,6 +50,12 @@ func RunServer(cfg *config.Config) error { 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 { diff --git a/internal/config/cli.go b/internal/config/cli.go index cfd5908..93c18ce 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -37,6 +37,7 @@ func declareFlags(o *Config) { flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") + flag.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 3304a8e..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 @@ -65,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", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7550463..7c27279 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -24,6 +24,7 @@ package storage import ( "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/driver/postgres" @@ -42,6 +43,7 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) + DeleteOldReports(olderThan time.Time) (int64, error) // Close closes the database connection Close() error @@ -115,6 +117,15 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { 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() From 9ff2ca30cce9cc271459eb132f7912cb1ed35c9f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 17:34:07 +0700 Subject: [PATCH 027/256] Add CI/CD --- .drone-manifest.yml | 22 +++++++ .drone.yml | 156 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 .drone-manifest.yml create mode 100644 .drone.yml 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 From 6096e043c6926ceba07e5f7d17dd880bd78c0f80 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 05:10:49 +0000 Subject: [PATCH 028/256] Update dependency @hey-api/openapi-ts to v0.85.2 From b2bbf0ee7809e8fdf1a204d74d4bb9c583319f9a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 18 Oct 2025 12:25:27 +0700 Subject: [PATCH 029/256] Add an auto-cleanup worker From e7aa80bef433813530b1534cf1f6e95e7f81d247 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Oct 2025 07:10:45 +0000 Subject: [PATCH 030/256] Update module golang.org/x/net to v0.46.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e51b1d5..843d4dc 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.45.0 + golang.org/x/net v0.46.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.0 diff --git a/go.sum b/go.sum index 939e263..708d83c 100644 --- a/go.sum +++ b/go.sum @@ -187,8 +187,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.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= From 7e603ddf4af82611d3047a6e4098defd13e01871 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 14:21:51 +0700 Subject: [PATCH 031/256] go mod tidy --- go.mod | 3 ++- go.sum | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 843d4dc..7604b07 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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 @@ -14,13 +15,13 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/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/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect diff --git a/go.sum b/go.sum index 708d83c..bc46bc0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -84,6 +88,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -149,6 +154,7 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 6097eb54c6d1f6de37bd89d868d798311a5bab66 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:11:23 +0700 Subject: [PATCH 032/256] Implement BIMI checks --- README.md | 4 +- api/openapi.yaml | 4 +- internal/analyzer/authentication.go | 86 ++++++++++ internal/analyzer/dns.go | 153 +++++++++++++++++ internal/analyzer/dns_test.go | 187 +++++++++++++++++++++ web/src/lib/components/PendingState.svelte | 2 +- web/src/routes/+page.svelte | 11 +- 7 files changed, 442 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c76e248..a509d8c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, 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 @@ -194,6 +194,8 @@ The deliverability score is calculated from 0 to 10 based on: - **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 diff --git a/api/openapi.yaml b/api/openapi.yaml index b278b6c..3dd2511 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -344,6 +344,8 @@ components: $ref: '#/components/schemas/AuthResult' dmarc: $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' AuthResult: type: object @@ -411,7 +413,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/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index 45df0a3..a0fd191 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -104,6 +104,13 @@ 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) + } + } } } @@ -214,6 +221,44 @@ 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 +} + // parseLegacySPF attempts to parse SPF from Received-SPF header func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") @@ -383,6 +428,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe }) } + // BIMI check (optional, informational only) + if results.Bimi != nil { + check := a.generateBIMICheck(results.Bimi) + checks = append(checks, check) + } + return checks } @@ -509,3 +560,38 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C 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.Info) + 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.Low) + 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.Low) + 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 +} diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go index 07c0346..b411386 100644 --- a/internal/analyzer/dns.go +++ b/internal/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 } @@ -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.Low) + 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.Low) + 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.Info) + + // 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/internal/analyzer/dns_test.go index fe501d5..12a6bd0 100644 --- a/internal/analyzer/dns_test.go +++ b/internal/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/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index ab9a6f8..ebe1f1d 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -47,7 +47,7 @@
  • - SPF, DKIM, DMARC + SPF, DKIM, DMARC, BIMI
  • DNS Records diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 6a62c0d..b86735c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -26,13 +26,20 @@ icon: "bi-shield-check", title: "Authentication", description: - "SPF, DKIM, and DMARC validation with detailed results and recommendations.", + "SPF, DKIM, DMARC, and BIMI validation with detailed results and recommendations.", variant: "primary" as const, }, + { + icon: "bi-patch-check", + title: "BIMI Support", + description: + "Brand Indicators for Message Identification - verify your brand logo configuration.", + variant: "info" as const, + }, { icon: "bi-globe", title: "DNS Records", - description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.", + description: "Verify MX, SPF, DKIM, DMARC, and BIMI records are properly configured.", variant: "success" as const, }, { From 8313fd7d98b51f935b8d17ad403586c30a9f4812 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:26:37 +0700 Subject: [PATCH 033/256] Implement ARC header check --- api/openapi.yaml | 25 +++ internal/analyzer/authentication.go | 259 ++++++++++++++++++++++++++-- internal/analyzer/content.go | 32 ++-- internal/analyzer/dns.go | 24 +-- internal/analyzer/rbl.go | 16 +- internal/analyzer/rbl_test.go | 6 +- internal/analyzer/scoring.go | 20 +-- internal/analyzer/spamassassin.go | 18 +- 8 files changed, 325 insertions(+), 75 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 3dd2511..d25c5c5 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -346,6 +346,8 @@ components: $ref: '#/components/schemas/AuthResult' bimi: $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' AuthResult: type: object @@ -369,6 +371,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: diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index a0fd191..a8c8df9 100644 --- a/internal/analyzer/authentication.go +++ b/internal/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 } @@ -111,6 +119,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Bimi = a.parseBIMIResult(part) } } + + // Parse ARC + if strings.HasPrefix(part, "arc=") { + if results.Arc == nil { + results.Arc = a.parseARCResult(part) + } + } } } @@ -259,6 +274,163 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { 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") @@ -389,7 +561,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No SPF authentication result found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), }) } @@ -407,7 +579,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No DKIM signature found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), }) } @@ -423,7 +595,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No DMARC authentication result found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Implement DMARC policy for your domain"), }) } @@ -434,6 +606,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe 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 } @@ -448,31 +626,31 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.Info) + 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.Critical) + 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.Medium) + 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.Low) + 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.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review your SPF record configuration") } @@ -495,19 +673,19 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "DKIM signature is valid" - check.Severity = api.PtrTo(api.Info) + 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.High) + 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.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") } @@ -537,19 +715,19 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "DMARC validation passed" - check.Severity = api.PtrTo(api.Info) + 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.High) + 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.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Configure DMARC policy for your domain") } @@ -572,19 +750,19 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che 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.Info) + 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.Low) + 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.Low) + 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") } @@ -595,3 +773,50 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che 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/internal/analyzer/content.go b/internal/analyzer/content.go index bad38c9..ac46259 100644 --- a/internal/analyzer/content.go +++ b/internal/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/dns.go b/internal/analyzer/dns.go index b411386..9a6d26f 100644 --- a/internal/analyzer/dns.go +++ b/internal/analyzer/dns.go @@ -537,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 @@ -548,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 @@ -577,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 } @@ -592,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") } @@ -611,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 @@ -619,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") @@ -639,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 @@ -681,14 +681,14 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { check.Status = api.CheckStatusInfo check.Score = 0.0 check.Message = "No BIMI record found (optional)" - check.Severity = api.PtrTo(api.Low) + 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.Low) + 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 } @@ -696,7 +696,7 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { 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.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) // Build details with logo and VMC URLs var detailsParts []string diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go index be7366c..fb01ae0 100644 --- a/internal/analyzer/rbl.go +++ b/internal/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/internal/analyzer/rbl_test.go index a75ef19..3a2fd44 100644 --- a/internal/analyzer/rbl_test.go +++ b/internal/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/scoring.go b/internal/analyzer/scoring.go index 07f6a34..115a497 100644 --- a/internal/analyzer/scoring.go +++ b/internal/analyzer/scoring.go @@ -351,13 +351,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 +386,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 +400,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 +420,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 +452,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") diff --git a/internal/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go index 78a6a72..474884e 100644 --- a/internal/analyzer/spamassassin.go +++ b/internal/analyzer/spamassassin.go @@ -217,7 +217,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 +260,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 +307,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 +320,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 From cd40b7c3eab55f729cc2b6e879b7bb7be4ae7891 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:39:21 +0700 Subject: [PATCH 034/256] Refactor authentication.go --- internal/analyzer/authentication.go | 315 -------- internal/analyzer/authentication_checks.go | 304 ++++++++ internal/analyzer/authentication_test.go | 846 +++++++++++++++++++++ internal/analyzer/scoring.go | 43 +- 4 files changed, 1191 insertions(+), 317 deletions(-) create mode 100644 internal/analyzer/authentication_checks.go create mode 100644 internal/analyzer/authentication_test.go diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index a8c8df9..d6fd600 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -505,318 +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.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/internal/analyzer/authentication_checks.go b/internal/analyzer/authentication_checks.go new file mode 100644 index 0000000..01298a0 --- /dev/null +++ b/internal/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/internal/analyzer/authentication_test.go b/internal/analyzer/authentication_test.go new file mode 100644 index 0000000..8328270 --- /dev/null +++ b/internal/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/scoring.go b/internal/analyzer/scoring.go index 115a497..03ab870 100644 --- a/internal/analyzer/scoring.go +++ b/internal/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) @@ -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 +} From 30f774c1fb02af3b9a682ad751319a9b03a059cf Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 07:40:52 +0700 Subject: [PATCH 035/256] Expose analyzer --- internal/app/cli_analyzer.go | 2 +- internal/receiver/receiver.go | 2 +- {internal => pkg}/analyzer/analyzer.go | 0 {internal => pkg}/analyzer/authentication.go | 0 {internal => pkg}/analyzer/authentication_checks.go | 0 {internal => pkg}/analyzer/authentication_test.go | 0 {internal => pkg}/analyzer/content.go | 0 {internal => pkg}/analyzer/content_test.go | 0 {internal => pkg}/analyzer/dns.go | 0 {internal => pkg}/analyzer/dns_test.go | 0 {internal => pkg}/analyzer/parser.go | 0 {internal => pkg}/analyzer/parser_test.go | 0 {internal => pkg}/analyzer/rbl.go | 0 {internal => pkg}/analyzer/rbl_test.go | 0 {internal => pkg}/analyzer/report.go | 0 {internal => pkg}/analyzer/report_test.go | 0 {internal => pkg}/analyzer/scoring.go | 0 {internal => pkg}/analyzer/scoring_test.go | 0 {internal => pkg}/analyzer/spamassassin.go | 0 {internal => pkg}/analyzer/spamassassin_test.go | 0 20 files changed, 2 insertions(+), 2 deletions(-) rename {internal => pkg}/analyzer/analyzer.go (100%) rename {internal => pkg}/analyzer/authentication.go (100%) rename {internal => pkg}/analyzer/authentication_checks.go (100%) rename {internal => pkg}/analyzer/authentication_test.go (100%) rename {internal => pkg}/analyzer/content.go (100%) rename {internal => pkg}/analyzer/content_test.go (100%) rename {internal => pkg}/analyzer/dns.go (100%) rename {internal => pkg}/analyzer/dns_test.go (100%) rename {internal => pkg}/analyzer/parser.go (100%) rename {internal => pkg}/analyzer/parser_test.go (100%) rename {internal => pkg}/analyzer/rbl.go (100%) rename {internal => pkg}/analyzer/rbl_test.go (100%) rename {internal => pkg}/analyzer/report.go (100%) rename {internal => pkg}/analyzer/report_test.go (100%) rename {internal => pkg}/analyzer/scoring.go (100%) rename {internal => pkg}/analyzer/scoring_test.go (100%) rename {internal => pkg}/analyzer/spamassassin.go (100%) rename {internal => pkg}/analyzer/spamassassin_test.go (100%) diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 87a4e0a..2cccf1b 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,9 +31,9 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // RunAnalyzer runs the standalone email analyzer (from stdin) diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index db1c2ea..1132b54 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -31,9 +31,9 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // EmailReceiver handles incoming emails from the MTA diff --git a/internal/analyzer/analyzer.go b/pkg/analyzer/analyzer.go similarity index 100% rename from internal/analyzer/analyzer.go rename to pkg/analyzer/analyzer.go diff --git a/internal/analyzer/authentication.go b/pkg/analyzer/authentication.go similarity index 100% rename from internal/analyzer/authentication.go rename to pkg/analyzer/authentication.go diff --git a/internal/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go similarity index 100% rename from internal/analyzer/authentication_checks.go rename to pkg/analyzer/authentication_checks.go diff --git a/internal/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go similarity index 100% rename from internal/analyzer/authentication_test.go rename to pkg/analyzer/authentication_test.go diff --git a/internal/analyzer/content.go b/pkg/analyzer/content.go similarity index 100% rename from internal/analyzer/content.go rename to pkg/analyzer/content.go 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 100% rename from internal/analyzer/dns.go rename to pkg/analyzer/dns.go diff --git a/internal/analyzer/dns_test.go b/pkg/analyzer/dns_test.go similarity index 100% rename from internal/analyzer/dns_test.go rename to pkg/analyzer/dns_test.go 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 100% rename from internal/analyzer/rbl.go rename to pkg/analyzer/rbl.go diff --git a/internal/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go similarity index 100% rename from internal/analyzer/rbl_test.go rename to pkg/analyzer/rbl_test.go 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 100% rename from internal/analyzer/scoring.go rename to pkg/analyzer/scoring.go 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 100% rename from internal/analyzer/spamassassin.go rename to pkg/analyzer/spamassassin.go diff --git a/internal/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go similarity index 100% rename from internal/analyzer/spamassassin_test.go rename to pkg/analyzer/spamassassin_test.go From 1fa7af4c2b751e6503f11e105e519bbdeebc7154 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 09:27:42 +0700 Subject: [PATCH 036/256] Fix spamassassin report details --- pkg/analyzer/spamassassin.go | 23 ++-- pkg/analyzer/spamassassin_test.go | 171 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 9 deletions(-) diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 474884e..b1b0e4e 100644 --- a/pkg/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) diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index 4682ed3..e7491db 100644 --- a/pkg/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) { From 3c58f5ccd59e8e9a7f2946b2e1469411e2796b44 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 20 Oct 2025 00:10:33 +0000 Subject: [PATCH 037/256] Lock file maintenance --- web/package-lock.json | 1158 ++++++----------------------------------- 1 file changed, 165 insertions(+), 993 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 714f1c0..4ea7ea6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -34,14 +34,6 @@ "vitest": "^3.2.4" } }, - "node_modules/@bufbuild/protobuf": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", - "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", - "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", @@ -548,13 +540,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -563,9 +555,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "version": "0.4.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": { @@ -626,9 +618,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, "license": "MIT", "engines": { @@ -639,9 +631,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -874,316 +866,6 @@ "node": ">= 8" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1203,9 +885,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "version": "4.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" ], @@ -1217,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "version": "4.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" ], @@ -1231,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "version": "4.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" ], @@ -1245,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "version": "4.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" ], @@ -1259,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", "cpu": [ "arm64" ], @@ -1273,9 +955,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "version": "4.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" ], @@ -1287,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "version": "4.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" ], @@ -1301,9 +983,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "version": "4.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" ], @@ -1315,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "version": "4.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" ], @@ -1329,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "version": "4.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" ], @@ -1343,9 +1025,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "version": "4.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" ], @@ -1357,9 +1039,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "version": "4.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" ], @@ -1371,9 +1053,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "version": "4.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" ], @@ -1385,9 +1067,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "version": "4.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" ], @@ -1399,9 +1081,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "version": "4.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" ], @@ -1413,9 +1095,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "version": "4.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" ], @@ -1427,9 +1109,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "version": "4.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" ], @@ -1441,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "version": "4.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" ], @@ -1455,9 +1137,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "version": "4.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" ], @@ -1469,9 +1151,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "version": "4.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" ], @@ -1483,9 +1165,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "version": "4.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" ], @@ -1497,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "version": "4.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" ], @@ -2210,14 +1892,6 @@ "node": ">=8" } }, - "node_modules/buffer-builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "dev": true, - "license": "MIT/X11", - "optional": true - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2393,14 +2067,6 @@ "color-support": "bin.js" } }, - "node_modules/colorjs.io": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", - "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/commander": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", @@ -2575,20 +2241,6 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/devalue": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz", @@ -2672,26 +2324,25 @@ } }, "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "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.0", - "@eslint/config-helpers": "^0.4.0", + "@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.37.0", + "@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", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -2750,9 +2401,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.12.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", - "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "version": "3.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": { @@ -2765,7 +2416,7 @@ "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", - "svelte-eslint-parser": "^1.3.0" + "svelte-eslint-parser": "^1.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2982,6 +2633,24 @@ "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", @@ -3154,14 +2823,6 @@ "node": ">= 4" } }, - "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3553,14 +3214,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -3588,13 +3241,6 @@ "node": "^14.16.0 || >=16.10.0" } }, - "node_modules/nypm/node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", - "dev": true, - "license": "MIT" - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -4014,9 +3660,9 @@ } }, "node_modules/rollup": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", "dependencies": { @@ -4030,28 +3676,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", + "@rollup/rollup-android-arm-eabi": "4.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" } }, @@ -4092,17 +3738,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -4116,395 +3751,6 @@ "node": ">=6" } }, - "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-embedded": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", - "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@bufbuild/protobuf": "^2.5.0", - "buffer-builder": "^0.2.0", - "colorjs.io": "^0.5.0", - "immutable": "^5.0.2", - "rxjs": "^7.4.0", - "supports-color": "^8.1.1", - "sync-child-process": "^1.0.2", - "varint": "^6.0.0" - }, - "bin": { - "sass": "dist/bin/sass.js" - }, - "engines": { - "node": ">=16.0.0" - }, - "optionalDependencies": { - "sass-embedded-all-unknown": "1.93.2", - "sass-embedded-android-arm": "1.93.2", - "sass-embedded-android-arm64": "1.93.2", - "sass-embedded-android-riscv64": "1.93.2", - "sass-embedded-android-x64": "1.93.2", - "sass-embedded-darwin-arm64": "1.93.2", - "sass-embedded-darwin-x64": "1.93.2", - "sass-embedded-linux-arm": "1.93.2", - "sass-embedded-linux-arm64": "1.93.2", - "sass-embedded-linux-musl-arm": "1.93.2", - "sass-embedded-linux-musl-arm64": "1.93.2", - "sass-embedded-linux-musl-riscv64": "1.93.2", - "sass-embedded-linux-musl-x64": "1.93.2", - "sass-embedded-linux-riscv64": "1.93.2", - "sass-embedded-linux-x64": "1.93.2", - "sass-embedded-unknown-all": "1.93.2", - "sass-embedded-win32-arm64": "1.93.2", - "sass-embedded-win32-x64": "1.93.2" - } - }, - "node_modules/sass-embedded-all-unknown": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz", - "integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==", - "cpu": [ - "!arm", - "!arm64", - "!riscv64", - "!x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-android-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz", - "integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz", - "integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz", - "integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz", - "integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz", - "integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz", - "integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz", - "integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz", - "integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz", - "integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz", - "integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz", - "integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz", - "integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz", - "integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz", - "integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-unknown-all": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz", - "integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "!android", - "!darwin", - "!linux", - "!win32" - ], - "dependencies": { - "sass": "1.93.2" - } - }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz", - "integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz", - "integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -4644,9 +3890,9 @@ } }, "node_modules/svelte": { - "version": "5.40.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.40.2.tgz", - "integrity": "sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==", + "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, @@ -4694,28 +3940,10 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/svelte-eslint-parser": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", - "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "version": "1.4.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": { @@ -4727,7 +3955,8 @@ "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.18.3" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -4741,31 +3970,6 @@ } } }, - "node_modules/sync-child-process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", - "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "sync-message-port": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4774,9 +3978,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "dev": true, "license": "MIT" }, @@ -4797,31 +4001,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4895,14 +4080,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4993,14 +4170,6 @@ "dev": true, "license": "MIT" }, - "node_modules/varint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/vite": { "version": "7.1.10", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz", @@ -5100,31 +4269,12 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5238,6 +4388,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5288,6 +4445,21 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 243ca4ba11403a9b3067ce748886e3b72530133b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 10:32:01 +0700 Subject: [PATCH 038/256] Add a banner in README.md --- README.md | 4 +++- banner.webp | Bin 0 -> 35526 bytes 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 banner.webp diff --git a/README.md b/README.md index a509d8c..b9db23c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# happyDeliver +# happyDeliver - Email Deliverability Tester + +![banner](banner.webp) An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring. diff --git a/banner.webp b/banner.webp new file mode 100644 index 0000000000000000000000000000000000000000..8ed7da1504341c7a0d4fdef607f8f4464bba1d01 GIT binary patch literal 35526 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 oUwUaYqf7-anIP Date: Mon, 20 Oct 2025 11:05:24 +0700 Subject: [PATCH 039/256] Improve branding --- web/src/lib/components/Logo.svelte | 42 +++++++++++ web/src/routes/+layout.svelte | 111 +++++++++++++++++++++++------ web/src/routes/+page.svelte | 10 +-- 3 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 web/src/lib/components/Logo.svelte diff --git a/web/src/lib/components/Logo.svelte b/web/src/lib/components/Logo.svelte new file mode 100644 index 0000000..6bba400 --- /dev/null +++ b/web/src/lib/components/Logo.svelte @@ -0,0 +1,42 @@ + + + + happyDeliver + + + + + + + + + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 9ed83d4..a1983f6 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -2,6 +2,9 @@ import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap-icons/font/bootstrap-icons.css"; import "../app.css"; + + import Logo from "$lib/components/Logo.svelte"; + interface Props { children?: import("svelte").Snippet; } @@ -10,13 +13,13 @@
    -
    + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b86735c..ecfbbdd 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -101,11 +101,11 @@ - happyDeliver - Email Deliverability Testing + happyDeliver. Test Your Email Deliverability. -
    +
    @@ -115,7 +115,7 @@ and more. Open-source, self-hosted, and privacy-focused.

    -
    +
    @@ -163,7 +163,7 @@
    -
    +
    From 0084fd9660977d0f45e0bd8e854ffba26119c611 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 11:15:04 +0700 Subject: [PATCH 040/256] Indicate when the next inbox check will be done --- web/src/lib/components/PendingState.svelte | 23 ++++++++++++++++++++-- web/src/routes/test/[test]/+page.svelte | 21 ++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index ebe1f1d..d749280 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -1,12 +1,22 @@
    @@ -32,7 +42,16 @@
    - Checking for email every 3 seconds... + {#if nextfetch} + Next inbox check in {nextfetch} second{#if nextfetch > 1}s{/if}... + {#if nbfetch > 0} + + {/if} + {:else} + Checking for email every 3 seconds... + {/if}
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index f70bc53..ac89f78 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -11,8 +11,15 @@ let loading = $state(true); let error = $state(null); let pollInterval: ReturnType | null = null; + let nextfetch = $state(23); + let nbfetch = $state(0); async function fetchTest() { + if (nbfetch > 0) { + nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5)); + } + nbfetch += 1; + try { const testResponse = await getTest({ path: { id: testId } }); if (testResponse.data) { @@ -35,7 +42,17 @@ } function startPolling() { - pollInterval = setInterval(fetchTest, 3000); + pollInterval = setInterval(() => { + nextfetch -= 1; + + if (nextfetch <= 0) { + if (!document.hidden) { + fetchTest(); + } else { + nextfetch = 1; + } + } + }, 1000); } function stopPolling() { @@ -82,7 +99,7 @@
    {:else if test && test.status !== "analyzed"} - + fetchTest()} /> {:else if report}
    From 3f5e2c6dd43376e5fcc8ad16f2802b2a8eaf1ecc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 11:41:19 +0700 Subject: [PATCH 041/256] Add option to add custom content before and --- web/routes.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/routes.go b/web/routes.go index 22fd15c..f67b453 100644 --- a/web/routes.go +++ b/web/routes.go @@ -23,6 +23,7 @@ package web import ( "encoding/json" + "flag" "io" "io/fs" "io/ioutil" @@ -41,9 +42,15 @@ import ( var ( indexTpl *template.Template + CustomBodyHTML = "" CustomHeadHTML = "" ) +func init() { + flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before ") + flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before ") +} + func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig := map[string]interface{}{} @@ -116,11 +123,12 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { v, _ := ioutil.ReadAll(resp.Body) - v2 := strings.Replace(string(v), "", "{{ .Head }}", 1) + v2 := strings.Replace(strings.Replace(string(v), "", "{{ .Head }}", 1), "", "{{ .Body }}", 1) indexTpl = template.Must(template.New("index.html").Parse(v2)) if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{ + "Body": CustomBodyHTML, "Head": CustomHeadHTML, }); err != nil { log.Println("Unable to return index.html:", err.Error()) From 849bdb53c513daeb54e8eca5533747a492073b58 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 12:06:53 +0700 Subject: [PATCH 042/256] Use base32 encoded UUID to reduce address size --- README.md | 6 +- api/openapi.yaml | 34 ++++++----- docker/postfix/transport_maps | 4 +- internal/api/handlers.go | 59 +++++++++++++++---- internal/receiver/receiver.go | 35 ++++++++++-- internal/utils/uuid.go | 75 +++++++++++++++++++++++++ pkg/analyzer/report.go | 5 +- pkg/analyzer/report_test.go | 9 ++- web/src/routes/test/[test]/+page.svelte | 2 +- 9 files changed, 188 insertions(+), 41 deletions(-) create mode 100644 internal/utils/uuid.go diff --git a/README.md b/README.md index b9db23c..a4ded59 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi ``` # Transport map - route test emails to happyDeliver LMTP server - # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 + /^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 ``` 3. Append the created file to `transport_maps` in your `main.cf`: @@ -144,7 +144,7 @@ Response: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "test-550e8400@localhost", + "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost", "status": "pending", "message": "Send your test email to the address above" } diff --git a/api/openapi.yaml b/api/openapi.yaml index d25c5c5..c569664 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -52,7 +52,7 @@ paths: tags: - tests summary: Get test status - description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available. + description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -60,7 +60,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Test status retrieved successfully @@ -88,7 +89,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Report retrieved successfully @@ -116,7 +118,8 @@ paths: required: true schema: type: string - format: uuid + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) responses: '200': description: Raw email retrieved successfully @@ -157,14 +160,14 @@ components: properties: id: type: string - format: uuid - description: Unique test identifier - example: "550e8400-e29b-41d4-a716-446655440000" + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" email: type: string format: email description: Unique test email address - example: "test-550e8400@example.com" + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" status: type: string enum: [pending, analyzed] @@ -180,12 +183,13 @@ components: properties: id: type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" email: type: string format: email - example: "test-550e8400@example.com" + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" status: type: string enum: [pending] @@ -205,12 +209,12 @@ components: properties: id: type: string - format: uuid - description: Report identifier + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) test_id: type: string - format: uuid - description: Associated test ID + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) score: type: number format: float diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index 49fdb98..cc1deed 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ # Transport map - route test emails to happyDeliver LMTP server -# Pattern: test-@domain.com -> LMTP on localhost:2525 +# Pattern: test-@domain.com -> LMTP on localhost:2525 -/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 +/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b66db2d..b53c391 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -32,6 +32,7 @@ import ( "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/internal/utils" ) // APIHandler implements the ServerInterface for handling API requests @@ -56,16 +57,19 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // Generate a unique test ID (no database record created) testID := uuid.New() - // Generate test email address + // Convert UUID to base32 string for the API response + base32ID := utils.UUIDToBase32(testID) + + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - testID.String(), + base32ID, h.config.Email.Domain, ) // Return response c.JSON(http.StatusCreated, TestResponse{ - Id: testID, + Id: base32ID, Email: openapi_types.Email(email), Status: TestResponseStatusPending, Message: stringPtr("Send your test email to the given address"), @@ -74,9 +78,20 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // GetTest retrieves test metadata // (GET /test/{id}) -func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { +func (h *APIHandler) GetTest(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + // Check if a report exists for this test ID - reportExists, err := h.storage.ReportExists(id) + reportExists, err := h.storage.ReportExists(testUUID) if err != nil { c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", @@ -94,10 +109,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { apiStatus = TestStatusPending } - // Generate test email address + // Generate test email address using Base32-encoded UUID email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - id.String(), + id, h.config.Email.Domain, ) @@ -110,8 +125,19 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { // GetReport retrieves the detailed analysis report // (GET /report/{id}) -func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { - reportJSON, _, err := h.storage.GetReport(id) +func (h *APIHandler) GetReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { c.JSON(http.StatusNotFound, Error{ @@ -134,8 +160,19 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { // GetRawEmail retrieves the raw annotated email // (GET /report/{id}/raw) -func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { - _, rawEmail, err := h.storage.GetReport(id) +func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { c.JSON(http.StatusNotFound, Error{ diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 1132b54..fb8d36e 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,6 +22,7 @@ package receiver import ( + "encoding/base32" "encoding/json" "fmt" "io" @@ -112,8 +113,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return nil } +// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID +// Hyphens are ignored during decoding +func base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens for decoding + encoded = strings.ReplaceAll(encoded, "-", "") + + // Convert to uppercase for Base32 decoding + encoded = strings.ToUpper(encoded) + + // Decode from Base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err) + } + + // Ensure we have exactly 16 bytes for UUID + if len(decoded) != 16 { + return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded)) + } + + // Convert bytes to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} + // extractTestID extracts the UUID from the test email address -// Expected format: test-@domain.com +// Expected format: test-@domain.com func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { // Remove angle brackets if present (e.g., ) email = strings.Trim(email, "<>") @@ -133,10 +160,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix) - // Parse UUID - testID, err := uuid.Parse(uuidStr) + // Decode Base32 to UUID + testID, err := base32ToUUID(uuidStr) if err != nil { - return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr) + return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err) } return testID, nil diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go new file mode 100644 index 0000000..ebbbbdf --- /dev/null +++ b/internal/utils/uuid.go @@ -0,0 +1,75 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package utils + +import ( + "encoding/base32" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding) +// with hyphens every 7 characters for better readability +func UUIDToBase32(id uuid.UUID) string { + // Use RFC 4648 Base32 encoding (URL-safe) + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:]) + // Convert to lowercase for better readability + encoded = strings.ToLower(encoded) + + // Insert hyphens every 7 characters + var result strings.Builder + for i, char := range encoded { + if i > 0 && i%7 == 0 { + result.WriteRune('-') + } + result.WriteRune(char) + } + + return result.String() +} + +// Base32ToUUID converts a base32-encoded string back to a UUID +// Accepts strings with or without hyphens +func Base32ToUUID(encoded string) (uuid.UUID, error) { + // Remove hyphens + encoded = strings.ReplaceAll(encoded, "-", "") + // Convert to uppercase for decoding + encoded = strings.ToUpper(encoded) + + // Decode base32 + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) + if err != nil { + return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err) + } + + // Ensure we have exactly 16 bytes for a UUID + if len(decoded) != 16 { + return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded)) + } + + // Convert byte slice to UUID + var id uuid.UUID + copy(id[:], decoded) + return id, nil +} diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index fe30c6c..d6a1e23 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -25,6 +25,7 @@ import ( "time" "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) @@ -96,8 +97,8 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu now := time.Now() report := &api.Report{ - Id: reportID, - TestId: testID, + Id: utils.UUIDToBase32(reportID), + TestId: utils.UUIDToBase32(testID), Score: results.Score.OverallScore, CreatedAt: now, } diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 4a8fe00..fce4a64 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -29,6 +29,7 @@ import ( "time" "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) @@ -106,12 +107,14 @@ func TestGenerateReport(t *testing.T) { } // Verify required fields - if report.Id == uuid.Nil { + if report.Id == "" { t.Error("Report ID should not be empty") } - if report.TestId != testID { - t.Errorf("TestId = %s, want %s", report.TestId, testID) + // Convert testID to base32 for comparison + expectedTestID := utils.UUIDToBase32(testID) + if report.TestId != expectedTestID { + t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID) } if report.Score < 0 || report.Score > 10 { diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index ac89f78..ca4c6b0 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -73,7 +73,7 @@ - {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."} + {test ? `Test ${test.id.slice(0, 7)} - happyDeliver` : "Loading..."}
    From 4d637214deb7849f6c6fb904c15969802a8b03c1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 12:18:30 +0700 Subject: [PATCH 043/256] Improve responsiveness --- web/src/lib/components/PendingState.svelte | 23 +++++++++++++++------- web/src/routes/+layout.svelte | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index d749280..d99f826 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -43,12 +43,21 @@
    {#if nextfetch} - Next inbox check in {nextfetch} second{#if nextfetch > 1}s{/if}... - {#if nbfetch > 0} - - {/if} +
    + Next inbox check in {nextfetch} second{#if nextfetch > 1}s{/if}... + {#if nbfetch > 0} + + {/if} +
    {:else} Checking for email every 3 seconds... {/if} @@ -62,7 +71,7 @@
    What we'll check:
    -
    +
    • diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a1983f6..f0031bb 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -19,7 +19,7 @@ - + Open-Source Email Deliverability Tester
    From 0107858ee66ee331809f9bd663f5265991675036 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 12:18:40 +0700 Subject: [PATCH 044/256] Simplify copy of the address --- .../lib/components/EmailAddressDisplay.svelte | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte index aa79f9e..0f62dd2 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -5,6 +5,7 @@ let { email }: Props = $props(); let copied = $state(false); + let inputElement: HTMLInputElement; async function copyToClipboard() { try { @@ -15,13 +16,26 @@ console.error("Failed to copy:", err); } } + + function handleFocus(event: FocusEvent) { + const target = event.target as HTMLInputElement; + target.select(); + copyToClipboard(); + } -
    -
    - {email} +
    +
    +
    {#if copied} - + Copied to clipboard! {/if} From 8bf3b500d93e98443020c678b713c2ad41350854 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 12:27:49 +0700 Subject: [PATCH 045/256] Create a new test when visiting /test/ --- web/src/routes/test/+page.ts | 21 +++++++++++++++++++++ web/src/routes/test/[test]/+page.svelte | 2 +- web/src/routes/test/[test]/+page.ts | 2 ++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/test/+page.ts create mode 100644 web/src/routes/test/[test]/+page.ts diff --git a/web/src/routes/test/+page.ts b/web/src/routes/test/+page.ts new file mode 100644 index 0000000..d2f88f2 --- /dev/null +++ b/web/src/routes/test/+page.ts @@ -0,0 +1,21 @@ +import { error, redirect, type Load } from "@sveltejs/kit"; + +import { createTest as apiCreateTest } from "$lib/api"; + +export const prerender = false; +export const ssr = false; + +export const load: Load = async ({}) => { + let response; + try { + response = await apiCreateTest(); + } catch (err) { + error(err.response.status, err.message); + } + + if (response.response.ok) { + redirect(302, `/test/${response.data.id}`); + } else { + error(response.response.status, response.error); + } +}; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index ca4c6b0..47edccc 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -132,7 +132,7 @@
    - + Test Another Email diff --git a/web/src/routes/test/[test]/+page.ts b/web/src/routes/test/[test]/+page.ts new file mode 100644 index 0000000..ae88a27 --- /dev/null +++ b/web/src/routes/test/[test]/+page.ts @@ -0,0 +1,2 @@ +export const prerender = false; +export const ssr = false; From e3d89dc953ae505ec0356468e62b865e6e845caa Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 14:31:08 +0700 Subject: [PATCH 046/256] Add colors on scores --- web/src/lib/components/ScoreCard.svelte | 59 ++++++++++++++++++++----- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 65aa706..c520c79 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -36,33 +36,72 @@ {#if summary}
    -
    +
    + = 3} + class:text-warning={summary.authentication_score < 3 && + summary.authentication_score >= 1.5} + class:text-danger={summary.authentication_score < 1.5} + > + {summary.authentication_score.toFixed(1)}/3 + Authentication - {summary.authentication_score.toFixed(1)}/3
    -
    +
    + = 2} + class:text-warning={summary.spam_score < 2 && summary.spam_score >= 1} + class:text-danger={summary.spam_score < 1} + > + {summary.spam_score.toFixed(1)}/2 + Spam Score - {summary.spam_score.toFixed(1)}/2
    -
    +
    + = 2} + class:text-warning={summary.blacklist_score < 2 && + summary.blacklist_score >= 1} + class:text-danger={summary.blacklist_score < 1} + > + {summary.blacklist_score.toFixed(1)}/2 + Blacklists - {summary.blacklist_score.toFixed(1)}/2
    -
    +
    + = 2} + class:text-warning={summary.content_score < 2 && + summary.content_score >= 1} + class:text-danger={summary.content_score < 1} + > + {summary.content_score.toFixed(1)}/2 + Content - {summary.content_score.toFixed(1)}/2
    -
    +
    + = 1} + class:text-warning={summary.header_score < 1 && + summary.header_score >= 0.5} + class:text-danger={summary.header_score < 0.5} + > + {summary.header_score.toFixed(1)}/1 + Headers - {summary.header_score.toFixed(1)}/1
    From 0ac51ac06d3345013c15076a42053e58db34700e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 19:25:48 +0700 Subject: [PATCH 047/256] Use grade instead of numeral notation --- api/openapi.yaml | 50 ++++--- pkg/analyzer/authentication_test.go | 10 +- pkg/analyzer/content.go | 48 +++--- pkg/analyzer/content_test.go | 16 +- pkg/analyzer/rbl.go | 21 +-- pkg/analyzer/rbl_test.go | 18 +-- pkg/analyzer/report.go | 1 + pkg/analyzer/report_test.go | 10 +- pkg/analyzer/scoring.go | 188 ++++++++++++++---------- pkg/analyzer/scoring_test.go | 50 +++---- pkg/analyzer/spamassassin.go | 24 +-- pkg/analyzer/spamassassin_test.go | 16 +- web/src/lib/components/CheckCard.svelte | 5 +- web/src/routes/test/[test]/+page.svelte | 100 ++++++++++++- 14 files changed, 355 insertions(+), 202 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index c569664..8852c42 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -204,6 +204,7 @@ components: - id - test_id - score + - grade - checks - created_at properties: @@ -219,9 +220,14 @@ components: type: number format: float minimum: 0 - maximum: 10 - description: Overall deliverability score (0-10) - example: 8.5 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" summary: $ref: '#/components/schemas/ScoreSummary' checks: @@ -260,37 +266,37 @@ components: type: number format: float minimum: 0 - maximum: 3 - description: SPF/DKIM/DMARC score (max 3 pts) - example: 2.8 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 spam_score: type: number format: float minimum: 0 - maximum: 2 - description: SpamAssassin score (max 2 pts) - example: 1.5 + maximum: 100 + description: SpamAssassin score (in percentage) + example: 15 blacklist_score: type: number format: float minimum: 0 - maximum: 2 - description: Blacklist check score (max 2 pts) - example: 2.0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 content_score: type: number format: float minimum: 0 - maximum: 2 - description: Content quality score (max 2 pts) - example: 1.8 + maximum: 100 + description: Content quality score (in percentage) + example: 18 header_score: type: number format: float minimum: 0 - maximum: 1 - description: Header quality score (max 1 pt) - example: 0.9 + maximum: 100 + description: Header quality score (in percentage) + example: 9 Check: type: object @@ -299,6 +305,7 @@ components: - name - status - score + - grade - message properties: category: @@ -319,7 +326,12 @@ components: type: number format: float description: Points contributed to total score - example: 1.0 + example: 10 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" message: type: string description: Human-readable result message diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 8328270..ecd5832 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -509,7 +509,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 3.0, + expectedScore: 30.0, }, { name: "SPF and DKIM only", @@ -521,7 +521,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "SPF fail, DKIM pass", @@ -533,7 +533,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "SPF softfail", @@ -542,7 +542,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultSoftfail, }, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "No authentication", @@ -559,7 +559,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 1.0, // Only SPF counted, not BIMI + expectedScore: 10.0, // Only SPF counted, not BIMI }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index ac46259..7c68323 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -745,7 +745,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap return check } -// GetContentScore calculates the content score (0-2 points) +// GetContentScore calculates the content score (0-20 points) func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { if results == nil { return 0.0 @@ -753,12 +753,12 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { var score float32 = 0.0 - // HTML validity (0.2 points) + // HTML validity (2 points) if results.HTMLValid { - score += 0.2 + score += 2.0 } - // Links (0.4 points) + // Links (4 points) if len(results.Links) > 0 { brokenLinks := 0 for _, link := range results.Links { @@ -767,14 +767,14 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if brokenLinks == 0 { - score += 0.4 + score += 4.0 } } else { // No links is neutral, give partial score - score += 0.2 + score += 2.0 } - // Images (0.3 points) + // Images (3 points) if len(results.Images) > 0 { noAltCount := 0 for _, img := range results.Images { @@ -783,47 +783,47 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if noAltCount == 0 { - score += 0.3 + score += 3.0 } else if noAltCount < len(results.Images) { - score += 0.15 + score += 1.5 } } else { // No images is neutral - score += 0.15 + score += 1.5 } - // Unsubscribe link (0.3 points) + // Unsubscribe link (3 points) if results.HasUnsubscribe { - score += 0.3 + score += 3.0 } - // Text consistency (0.3 points) + // Text consistency (3 points) if results.TextPlainRatio >= 0.3 { - score += 0.3 + score += 3.0 } - // Image ratio (0.3 points) + // Image ratio (3 points) if results.ImageTextRatio <= 5.0 { - score += 0.3 + score += 3.0 } else if results.ImageTextRatio <= 10.0 { - score += 0.15 + score += 1.5 } - // Penalize suspicious URLs (deduct up to 0.5 points) + // Penalize suspicious URLs (deduct up to 5 points) if len(results.SuspiciousURLs) > 0 { - penalty := float32(len(results.SuspiciousURLs)) * 0.1 - if penalty > 0.5 { - penalty = 0.5 + penalty := float32(len(results.SuspiciousURLs)) * 1.0 + if penalty > 5.0 { + penalty = 5.0 } score -= penalty } - // Ensure score is between 0 and 2 + // Ensure score is between 0 and 20 if score < 0 { score = 0 } - if score > 2.0 { - score = 2.0 + if score > 20.0 { + score = 20.0 } return score diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index 342f3cb..c82d4a8 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -946,8 +946,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.8, ImageTextRatio: 3.0, }, - minScore: 1.8, - maxScore: 2.0, + minScore: 18.0, + maxScore: 20.0, }, { name: "Poor content", @@ -961,7 +961,7 @@ func TestGetContentScore(t *testing.T) { SuspiciousURLs: []string{"url1", "url2"}, }, minScore: 0.0, - maxScore: 0.5, + maxScore: 5.0, }, { name: "Average content", @@ -973,8 +973,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.5, ImageTextRatio: 4.0, }, - minScore: 1.0, - maxScore: 1.8, + minScore: 10.0, + maxScore: 18.0, }, } @@ -988,9 +988,9 @@ func TestGetContentScore(t *testing.T) { t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } - // Ensure score is capped at 2.0 - if score > 2.0 { - t.Errorf("Score %v exceeds maximum of 2.0", score) + // Ensure score is capped at 20.0 + if score > 20.0 { + t.Errorf("Score %v exceeds maximum of 20.0", score) } // Ensure score is not negative diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index fb01ae0..3904c6f 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -238,26 +238,26 @@ func (r *RBLChecker) reverseIP(ipStr string) string { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points) +// GetBlacklistScore calculates the blacklist contribution to deliverability (0-20 points) // Scoring: -// - Not listed on any RBL: 2 points (excellent) -// - Listed on 1 RBL: 1 point (warning) -// - Listed on 2-3 RBLs: 0.5 points (poor) +// - Not listed on any RBL: 20 points (excellent) +// - Listed on 1 RBL: 10 points (warning) +// - Listed on 2-3 RBLs: 5 points (poor) // - Listed on 4+ RBLs: 0 points (critical) func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt - return 2.0 + return 20.0 } listedCount := results.ListedCount if listedCount == 0 { - return 2.0 + return 20.0 } else if listedCount == 1 { - return 1.0 + return 10.0 } else if listedCount <= 3 { - return 0.5 + return 5.0 } return 0.0 @@ -277,7 +277,8 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Category: api.Blacklist, Name: "RBL Check", Status: api.CheckStatusWarn, - Score: 1.0, + Score: 10.0, + Grade: ScoreToCheckGrade((10.0 / 20.0) * 100), Message: "No public IP addresses found to check", Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), @@ -309,6 +310,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { score := r.GetBlacklistScore(results) check.Score = score + check.Grade = ScoreToCheckGrade((score / 20.0) * 100) totalChecks := len(results.Checks) listedCount := results.ListedCount @@ -351,6 +353,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), Status: api.CheckStatusFail, Score: 0.0, + Grade: ScoreToCheckGrade(0.0), } check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 3a2fd44..0bf8c0e 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -272,14 +272,14 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Nil results", results: nil, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "No IPs checked", results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Not listed on any RBL", @@ -287,7 +287,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Listed on 1 RBL", @@ -295,7 +295,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "Listed on 2 RBLs", @@ -303,7 +303,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 3 RBLs", @@ -311,7 +311,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 4+ RBLs", @@ -350,7 +350,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), // 6 default RBLs }, expectedStatus: api.CheckStatusPass, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Listed on 1 RBL", @@ -360,7 +360,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "Listed on 2 RBLs", @@ -370,7 +370,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 4+ RBLs", diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index d6a1e23..79799b9 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -100,6 +100,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu Id: utils.UUIDToBase32(reportID), TestId: utils.UUIDToBase32(testID), Score: results.Score.OverallScore, + Grade: ScoreToReportGrade(results.Score.OverallScore), CreatedAt: now, } diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index fce4a64..0dd7e8c 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -88,7 +88,7 @@ func TestAnalyzeEmail(t *testing.T) { } // Verify score is within bounds - if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { + if results.Score.OverallScore < 0 || results.Score.OverallScore > 100 { t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) } } @@ -117,7 +117,7 @@ func TestGenerateReport(t *testing.T) { t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID) } - if report.Score < 0 || report.Score > 10 { + if report.Score < 0 || report.Score > 100 { t.Errorf("Score %v is out of bounds", report.Score) } @@ -137,13 +137,13 @@ func TestGenerateReport(t *testing.T) { if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) } - if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 20 { t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) } - if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 20 { t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) } - if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 10 { t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) } } diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 03ab870..7d5184f 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -29,6 +29,36 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) +// ScoreToGrade converts a percentage score (0-100) to a letter grade +func ScoreToGrade(score float32) string { + switch { + case score >= 97: + return "A+" + case score >= 93: + return "A" + case score >= 85: + return "B" + case score >= 75: + return "C" + case score >= 65: + return "D" + case score >= 50: + return "E" + default: + return "F" + } +} + +// ScoreToCheckGrade converts a percentage score to an api.CheckGrade +func ScoreToCheckGrade(score float32) api.CheckGrade { + return api.CheckGrade(ScoreToGrade(score)) +} + +// ScoreToReportGrade converts a percentage score to an api.ReportGrade +func ScoreToReportGrade(score float32) api.ReportGrade { + return api.ReportGrade(ScoreToGrade(score)) +} + // DeliverabilityScorer aggregates all analysis results and computes overall score type DeliverabilityScorer struct{} @@ -86,12 +116,12 @@ func (s *DeliverabilityScorer) CalculateScore( // Calculate header quality score result.HeaderScore = s.calculateHeaderScore(email) - // Calculate overall score (out of 10) + // Calculate overall score (out of 100) result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore // Ensure score is within bounds - if result.OverallScore > 10.0 { - result.OverallScore = 10.0 + if result.OverallScore > 100.0 { + result.OverallScore = 100.0 } if result.OverallScore < 0.0 { result.OverallScore = 0.0 @@ -103,37 +133,37 @@ func (s *DeliverabilityScorer) CalculateScore( // Build category breakdown result.CategoryBreakdown["Authentication"] = CategoryScore{ Score: result.AuthScore, - MaxScore: 3.0, - Percentage: (result.AuthScore / 3.0) * 100, - Status: s.getCategoryStatus(result.AuthScore, 3.0), + MaxScore: 30.0, + Percentage: result.AuthScore, + Status: s.getCategoryStatus(result.AuthScore, 30.0), } result.CategoryBreakdown["Spam Filters"] = CategoryScore{ Score: result.SpamScore, - MaxScore: 2.0, - Percentage: (result.SpamScore / 2.0) * 100, - Status: s.getCategoryStatus(result.SpamScore, 2.0), + MaxScore: 20.0, + Percentage: result.SpamScore, + Status: s.getCategoryStatus(result.SpamScore, 20.0), } result.CategoryBreakdown["Blacklists"] = CategoryScore{ Score: result.BlacklistScore, - MaxScore: 2.0, - Percentage: (result.BlacklistScore / 2.0) * 100, - Status: s.getCategoryStatus(result.BlacklistScore, 2.0), + MaxScore: 20.0, + Percentage: result.BlacklistScore, + Status: s.getCategoryStatus(result.BlacklistScore, 20.0), } result.CategoryBreakdown["Content Quality"] = CategoryScore{ Score: result.ContentScore, - MaxScore: 2.0, - Percentage: (result.ContentScore / 2.0) * 100, - Status: s.getCategoryStatus(result.ContentScore, 2.0), + MaxScore: 20.0, + Percentage: result.ContentScore, + Status: s.getCategoryStatus(result.ContentScore, 20.0), } result.CategoryBreakdown["Email Structure"] = CategoryScore{ Score: result.HeaderScore, - MaxScore: 1.0, - Percentage: (result.HeaderScore / 1.0) * 100, - Status: s.getCategoryStatus(result.HeaderScore, 1.0), + MaxScore: 10.0, + Percentage: result.HeaderScore, + Status: s.getCategoryStatus(result.HeaderScore, 10.0), } // Generate recommendations @@ -142,7 +172,7 @@ func (s *DeliverabilityScorer) CalculateScore( return result } -// calculateHeaderScore evaluates email structural quality (0-1 point) +// calculateHeaderScore evaluates email structural quality (0-10 points) func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { if email == nil { return 0.0 @@ -167,14 +197,14 @@ func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 } } - // Score based on required headers (0.4 points) + // Score based on required headers (4 points) if presentHeaders == requiredHeaders { - score += 0.4 + score += 4.0 } else { - score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) + score += 4.0 * (float32(presentHeaders) / float32(requiredHeaders)) } - // Check recommended headers (0.3 points) + // Check recommended headers (3 points) recommendedHeaders := []string{"Subject", "To", "Reply-To"} recommendedPresent := 0 for _, header := range recommendedHeaders { @@ -182,23 +212,23 @@ func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 recommendedPresent++ } } - score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) + score += 3.0 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - // Check for proper MIME structure (0.2 points) + // Check for proper MIME structure (2 points) if len(email.Parts) > 0 { - score += 0.2 + score += 2.0 } - // Check Message-ID format (0.1 points) + // Check Message-ID format (1 point) if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { if s.isValidMessageID(messageID) { - score += 0.1 + score += 1.0 } } - // Ensure score doesn't exceed 1.0 - if score > 1.0 { - score = 1.0 + // Ensure score doesn't exceed 10.0 + if score > 10.0 { + score = 10.0 } return score @@ -229,16 +259,16 @@ func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { return len(parts[0]) > 0 && len(parts[1]) > 0 } -// determineRating determines the rating based on overall score +// determineRating determines the rating based on overall score (0-100) func (s *DeliverabilityScorer) determineRating(score float32) string { switch { - case score >= 9.0: + case score >= 90.0: return "Excellent" - case score >= 7.0: + case score >= 70.0: return "Good" - case score >= 5.0: + case score >= 50.0: return "Fair" - case score >= 3.0: + case score >= 30.0: return "Poor" default: return "Critical" @@ -263,38 +293,38 @@ func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { var recommendations []string - // Authentication recommendations - if result.AuthScore < 2.0 { + // Authentication recommendations (0-30 points) + if result.AuthScore < 20.0 { recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") - } else if result.AuthScore < 3.0 { + } else if result.AuthScore < 30.0 { recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") } - // Spam recommendations - if result.SpamScore < 1.0 { + // Spam recommendations (0-20 points) + if result.SpamScore < 10.0 { recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") - } else if result.SpamScore < 1.5 { + } else if result.SpamScore < 15.0 { recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") } - // Blacklist recommendations - if result.BlacklistScore < 1.0 { + // Blacklist recommendations (0-20 points) + if result.BlacklistScore < 10.0 { recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") - } else if result.BlacklistScore < 2.0 { + } else if result.BlacklistScore < 20.0 { recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") } - // Content recommendations - if result.ContentScore < 1.0 { + // Content recommendations (0-20 points) + if result.ContentScore < 10.0 { recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") - } else if result.ContentScore < 1.5 { + } else if result.ContentScore < 15.0 { recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") } - // Header recommendations - if result.HeaderScore < 0.5 { + // Header recommendations (0-10 points) + if result.HeaderScore < 5.0 { recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") - } else if result.HeaderScore < 1.0 { + } else if result.HeaderScore < 10.0 { recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") } @@ -349,13 +379,15 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) if len(missing) == 0 { check.Status = api.CheckStatusPass - check.Score = 0.4 + check.Score = 4.0 + check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) 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.Grade = ScoreToCheckGrade(0.0) check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") @@ -384,13 +416,15 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa if len(missing) == 0 { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3.0 + check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) 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.Score = 1.5 + check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) 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") @@ -399,6 +433,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa } else { check.Status = api.CheckStatusWarn check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Missing all recommended headers" check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") @@ -419,19 +454,22 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C if messageID == "" { check.Status = api.CheckStatusFail check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Message = "Message-ID header is missing" check.Advice = api.PtrTo("Add a unique Message-ID header to your email") } else if !s.isValidMessageID(messageID) { check.Status = api.CheckStatusWarn - check.Score = 0.05 + check.Score = 0.5 + check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) 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.Score = 1.0 + check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Message-ID is properly formatted" check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") @@ -451,12 +489,14 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a if len(email.Parts) == 0 { check.Status = api.CheckStatusWarn check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "No MIME parts detected" check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") } else { check.Status = api.CheckStatusPass - check.Score = 0.2 + check.Score = 2.0 + check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) 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") @@ -481,17 +521,17 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { var summary strings.Builder - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating)) + summary.WriteString(fmt.Sprintf("Overall Score: %.1f/100 (%s) - Grade: %s\n\n", result.OverallScore, result.Rating, ScoreToGrade(result.OverallScore))) summary.WriteString("Category Breakdown:\n") - summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n", + summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/30.0 (%.0f%%) - %s\n", result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) - summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n", + summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/20.0 (%.0f%%) - %s\n", result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) - summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n", + summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/20.0 (%.0f%%) - %s\n", result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) - summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n", + summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/20.0 (%.0f%%) - %s\n", result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) - summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n", + summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/10.0 (%.0f%%) - %s\n", result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) if len(result.Recommendations) > 0 { @@ -504,41 +544,41 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { return summary.String() } -// GetAuthenticationScore calculates the authentication score (0-3 points) +// GetAuthenticationScore calculates the authentication score (0-30 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 + // SPF: 10 points for pass, 5 for neutral/softfail, 0 for fail if results.Spf != nil { switch results.Spf.Result { case api.AuthResultResultPass: - score += 1.0 + score += 10.0 case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 0.5 + score += 5.0 } } - // DKIM: 1 point for at least one pass + // DKIM: 10 points 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 + score += 10.0 break } } } - // DMARC: 1 point for pass + // DMARC: 10 points for pass if results.Dmarc != nil { switch results.Dmarc.Result { case api.AuthResultResultPass: - score += 1.0 + score += 10.0 } } - // Cap at 3 points maximum - if score > 3.0 { - score = 3.0 + // Cap at 30 points maximum + if score > 30.0 { + score = 30.0 } return score diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go index b28182d..b4c756a 100644 --- a/pkg/analyzer/scoring_test.go +++ b/pkg/analyzer/scoring_test.go @@ -125,8 +125,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.7, - maxScore: 1.0, + minScore: 7.0, + maxScore: 10.0, }, { name: "Missing required headers", @@ -136,7 +136,7 @@ func TestCalculateHeaderScore(t *testing.T) { }), }, minScore: 0.0, - maxScore: 0.4, + maxScore: 4.0, }, { name: "Required only, no recommended", @@ -150,8 +150,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.4, - maxScore: 0.8, + minScore: 4.0, + maxScore: 8.0, }, { name: "Invalid Message-ID format", @@ -168,8 +168,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.7, - maxScore: 1.0, + minScore: 7.0, + maxScore: 10.0, }, } @@ -191,16 +191,16 @@ func TestDetermineRating(t *testing.T) { score float32 expected string }{ - {name: "Excellent - 10.0", score: 10.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 9.5, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 9.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 8.5, expected: "Good"}, - {name: "Good - 7.0", score: 7.0, expected: "Good"}, - {name: "Fair - 6.5", score: 6.5, expected: "Fair"}, - {name: "Fair - 5.0", score: 5.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 4.5, expected: "Poor"}, - {name: "Poor - 3.0", score: 3.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 2.5, expected: "Critical"}, + {name: "Excellent - 10.0", score: 100.0, expected: "Excellent"}, + {name: "Excellent - 9.5", score: 95.0, expected: "Excellent"}, + {name: "Excellent - 9.0", score: 90.0, expected: "Excellent"}, + {name: "Good - 8.5", score: 85.0, expected: "Good"}, + {name: "Good - 7.0", score: 70.0, expected: "Good"}, + {name: "Fair - 6.5", score: 65.0, expected: "Fair"}, + {name: "Fair - 5.0", score: 50.0, expected: "Fair"}, + {name: "Poor - 4.5", score: 45.0, expected: "Poor"}, + {name: "Poor - 3.0", score: 30.0, expected: "Poor"}, + {name: "Critical - 2.5", score: 25.0, expected: "Critical"}, {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, } @@ -294,8 +294,8 @@ func TestCalculateScore(t *testing.T) { MessageID: "", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 9.0, - maxScore: 10.0, + minScore: 90.0, + maxScore: 100.0, expectedRating: "Excellent", }, { @@ -330,7 +330,7 @@ func TestCalculateScore(t *testing.T) { }), }, minScore: 0.0, - maxScore: 5.0, + maxScore: 50.0, expectedRating: "Poor", }, { @@ -366,8 +366,8 @@ func TestCalculateScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 6.0, - maxScore: 9.0, + minScore: 60.0, + maxScore: 90.0, expectedRating: "Good", }, } @@ -399,8 +399,8 @@ func TestCalculateScore(t *testing.T) { } // Verify score is within bounds - if result.OverallScore < 0.0 || result.OverallScore > 10.0 { - t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore) + if result.OverallScore < 0.0 || result.OverallScore > 100.0 { + t.Errorf("OverallScore %v is out of bounds [0.0, 100.0]", result.OverallScore) } // Verify category breakdown exists @@ -535,7 +535,7 @@ func TestGenerateRequiredHeadersCheck(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, + expectedScore: 4.0, }, { name: "Missing all required headers", diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index b1b0e4e..2a3ff60 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -174,12 +174,12 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points) +// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-20 points) // Scoring: -// - Score <= 0: 2 points (excellent) -// - Score < required: 1.5 points (good) -// - Score slightly above required (< 2x): 1 point (borderline) -// - Score moderately high (< 3x required): 0.5 points (poor) +// - Score <= 0: 20 points (excellent) +// - Score < required: 15 points (good) +// - Score slightly above required (< 2x): 10 points (borderline) +// - Score moderately high (< 3x required): 5 points (poor) // - Score very high: 0 points (spam) func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { if result == nil { @@ -194,17 +194,17 @@ func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) // Calculate deliverability score if score <= 0 { - return 2.0 + return 20.0 } else if score < required { - // Linear scaling from 1.5 to 2.0 based on how negative/low the score is + // Linear scaling from 15 to 20 based on how negative/low the score is ratio := score / required - return 1.5 + (0.5 * (1.0 - float32(ratio))) + return 15.0 + (5.0 * (1.0 - float32(ratio))) } else if score < required*2 { // Slightly above threshold - return 1.0 + return 10.0 } else if score < required*3 { // Moderately high - return 0.5 + return 5.0 } // Very high spam score @@ -221,6 +221,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe Name: "SpamAssassin Analysis", Status: api.CheckStatusWarn, Score: 0.0, + Grade: ScoreToCheckGrade(0.0), Message: "No SpamAssassin headers found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), @@ -260,6 +261,7 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) delivScore := a.GetSpamAssassinScore(result) check.Score = delivScore + check.Grade = ScoreToCheckGrade((delivScore / 20.0) * 100) // Determine status and message based on score if score <= 0 { @@ -318,6 +320,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec check.Severity = api.PtrTo(api.CheckSeverityMedium) } check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) check.Advice = &advice @@ -325,6 +328,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Positive indicator (decreases spam score) check.Status = api.CheckStatusPass check.Score = 1.0 + check.Grade = ScoreToCheckGrade((1.0 / 20.0) * 100) 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) diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index e7491db..deed1c7 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -169,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Good score (below threshold)", @@ -177,8 +177,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 2.0, RequiredScore: 5.0, }, - minScore: 1.5, - maxScore: 2.0, + minScore: 15.0, + maxScore: 20.0, }, { name: "Borderline (just above threshold)", @@ -186,7 +186,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "High spam score", @@ -194,7 +194,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Very high spam score", @@ -618,7 +618,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Test GetSpamAssassinScore score := analyzer.GetSpamAssassinScore(result) - if score != 2.0 { + if score != 20.0 { t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) } @@ -639,8 +639,8 @@ func TestAnalyzeRealEmailExample(t *testing.T) { 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) + if mainCheck.Score != 20.0 { + t.Errorf("Main check score = %v, want 20.0", mainCheck.Score) } // Log all checks for debugging diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte index bc5741c..abd200f 100644 --- a/web/src/lib/components/CheckCard.svelte +++ b/web/src/lib/components/CheckCard.svelte @@ -31,10 +31,7 @@
    -
    -
    {check.name}
    - {check.category} -
    +
    {check.name}
    {check.score.toFixed(1)} pts
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 47edccc..7672fa8 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -14,6 +14,20 @@ let nextfetch = $state(23); let nbfetch = $state(0); + // Group checks by category + let groupedChecks = $derived(() => { + if (!report) return {}; + + const groups: Record = {}; + for (const check of report.checks) { + if (!groups[check.category]) { + groups[check.category] = []; + } + groups[check.category].push(check); + } + return groups; + }); + async function fetchTest() { if (nbfetch > 0) { nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5)); @@ -70,6 +84,56 @@ onDestroy(() => { stopPolling(); }); + + function getCategoryIcon(category: string): string { + switch (category) { + case "authentication": + return "bi-shield-check"; + case "dns": + return "bi-diagram-3"; + case "content": + return "bi-file-text"; + case "blacklist": + return "bi-shield-exclamation"; + case "headers": + return "bi-list-ul"; + case "spam": + return "bi-filter"; + default: + return "bi-question-circle"; + } + } + + function getCategoryScore(checks: typeof report.checks): number { + return checks.reduce((sum, check) => sum + check.score, 0); + } + + function getCategoryMaxScore(category: string): number { + switch (category) { + case "authentication": + return 3; + case "spam": + return 2; + case "blacklist": + return 2; + case "content": + return 2; + case "headers": + return 1; + case "dns": + return 0; // DNS checks contribute to other categories + default: + return 0; + } + } + + function getScoreColorClass(score: number, maxScore: number): string { + if (maxScore === 0) return "text-muted"; + const percentage = (score / maxScore) * 100; + if (percentage >= 80) return "text-success"; + if (percentage >= 50) return "text-warning"; + return "text-danger"; + } @@ -114,8 +178,23 @@

    Detailed Checks

    - {#each report.checks as check} - + {#each Object.entries(groupedChecks()) as [category, checks]} + {@const categoryScore = getCategoryScore(checks)} + {@const maxScore = getCategoryMaxScore(category)} +
    +

    + + + {category} + + + {categoryScore.toFixed(1)}{#if maxScore > 0} / {maxScore}{/if} pts + +

    + {#each checks as check} + + {/each} +
    {/each}
    @@ -157,4 +236,21 @@ transform: translateY(0); } } + + .category-section { + margin-bottom: 2rem; + } + + .category-title { + font-size: 1.25rem; + font-weight: 600; + color: #495057; + padding-bottom: 0.5rem; + border-bottom: 2px solid #e9ecef; + } + + .category-score { + font-size: 1rem; + font-weight: 700; + } From dfc0eeb3239a950e8cfaeb629529dfd4f2ee9dde Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 19:33:23 +0700 Subject: [PATCH 048/256] New route to perform a new analysis of the email --- api/openapi.yaml | 35 +++++++++++++ internal/api/handlers.go | 67 ++++++++++++++++++++++++- internal/app/server.go | 6 ++- internal/storage/storage.go | 13 +++++ pkg/analyzer/analyzer.go | 30 +++++++++++ web/src/routes/test/[test]/+page.svelte | 36 ++++++++++++- 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8852c42..e7ca45c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -134,6 +134,41 @@ paths: schema: $ref: '#/components/schemas/Error' + /report/{id}/reanalyze: + post: + tags: + - reports + summary: Reanalyze email and regenerate report + description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes. + operationId: reanalyzeReport + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) + responses: + '200': + description: Report regenerated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '404': + description: Email not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error during reanalysis + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: tags: diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b53c391..3b57747 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -35,18 +35,26 @@ import ( "git.happydns.org/happyDeliver/internal/utils" ) +// EmailAnalyzer defines the interface for email analysis +// This interface breaks the circular dependency with pkg/analyzer +type EmailAnalyzer interface { + AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) +} + // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { storage storage.Storage config *config.Config + analyzer EmailAnalyzer startTime time.Time } // NewAPIHandler creates a new API handler -func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { +func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { return &APIHandler{ storage: store, config: cfg, + analyzer: analyzer, startTime: time.Now(), } } @@ -192,6 +200,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { c.Data(http.StatusOK, "text/plain", rawEmail) } +// ReanalyzeReport re-analyzes an existing email and regenerates the report +// (POST /report/{id}/reanalyze) +func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + // Retrieve the existing report (mainly to get the raw email) + _, rawEmail, err := h.storage.GetReport(testUUID) + if err != nil { + if err == storage.ErrNotFound { + c.JSON(http.StatusNotFound, Error{ + Error: "not_found", + Message: "Email not found", + }) + return + } + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to retrieve email", + Details: stringPtr(err.Error()), + }) + return + } + + // Re-analyze the email using the current analyzer + reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "analysis_error", + Message: "Failed to re-analyze email", + Details: stringPtr(err.Error()), + }) + return + } + + // Update the report in storage + if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to update report", + Details: stringPtr(err.Error()), + }) + return + } + + // Return the updated report JSON directly + c.Data(http.StatusOK, "application/json", reportJSON) +} + // GetStatus retrieves service health status // (GET /status) func (h *APIHandler) GetStatus(c *gin.Context) { diff --git a/internal/app/server.go b/internal/app/server.go index 332516b..0c70eef 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -32,6 +32,7 @@ import ( "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" "git.happydns.org/happyDeliver/web" ) @@ -63,8 +64,11 @@ func RunServer(cfg *config.Config) error { } }() + // Create analyzer adapter for API + analyzerAdapter := analyzer.NewAPIAdapter(cfg) + // Create API handler - handler := api.NewAPIHandler(store, cfg) + handler := api.NewAPIHandler(store, cfg, analyzerAdapter) // Set up Gin router if os.Getenv("GIN_MODE") == "" { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7c27279..d8a8cb4 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -43,6 +43,7 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) + UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) // Close closes the database connection @@ -117,6 +118,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { return dbReport.ReportJSON, dbReport.RawEmail, nil } +// UpdateReport updates the report JSON for an existing test ID +func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error { + result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON) + if result.Error != nil { + return fmt.Errorf("failed to update report: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + // DeleteOldReports deletes reports older than the specified time func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 3588280..dd082a5 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -23,6 +23,7 @@ package analyzer import ( "bytes" + "encoding/json" "fmt" "github.com/google/uuid" @@ -85,3 +86,32 @@ func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { } return a.generator.GetScoreSummaryText(result.Results) } + +// APIAdapter adapts the EmailAnalyzer to work with the API package +// This adapter implements the interface expected by the API handler +type APIAdapter struct { + analyzer *EmailAnalyzer +} + +// NewAPIAdapter creates a new API adapter for the email analyzer +func NewAPIAdapter(cfg *config.Config) *APIAdapter { + return &APIAdapter{ + analyzer: NewEmailAnalyzer(cfg), + } +} + +// AnalyzeEmailBytes performs analysis and returns JSON bytes directly +func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { + result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return nil, err + } + + // Marshal report to JSON + reportJSON, err := json.Marshal(result.Report) + if err != nil { + return nil, fmt.Errorf("failed to marshal report: %w", err) + } + + return reportJSON, nil +} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 7672fa8..db3e447 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,7 +1,7 @@ @@ -208,9 +227,22 @@
    {/if} - +
    + Test Another Email From 74aee54432316c2ad145a56ceac484767e7d95d9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 12:52:20 +0700 Subject: [PATCH 049/256] Score as percentages --- api/openapi.yaml | 21 +- internal/app/cli_analyzer.go | 4 - pkg/analyzer/analyzer.go | 8 - pkg/analyzer/authentication.go | 56 +-- pkg/analyzer/authentication_checks.go | 107 ++--- pkg/analyzer/authentication_test.go | 54 +-- pkg/analyzer/content.go | 78 ++-- pkg/analyzer/content_test.go | 42 +- pkg/analyzer/dns.go | 44 +- pkg/analyzer/dns_test.go | 42 +- pkg/analyzer/headers.go | 303 ++++++++++++++ pkg/analyzer/headers_test.go | 324 +++++++++++++++ pkg/analyzer/rbl.go | 33 +- pkg/analyzer/rbl_test.go | 26 +- pkg/analyzer/report.go | 96 +++-- pkg/analyzer/report_test.go | 38 +- pkg/analyzer/scoring.go | 527 +----------------------- pkg/analyzer/scoring_test.go | 522 +---------------------- pkg/analyzer/spamassassin.go | 49 +-- pkg/analyzer/spamassassin_test.go | 32 +- web/src/lib/components/CheckCard.svelte | 4 +- web/src/lib/components/ScoreCard.svelte | 69 ++-- web/src/routes/test/[test]/+page.svelte | 36 +- 23 files changed, 1027 insertions(+), 1488 deletions(-) create mode 100644 pkg/analyzer/headers.go create mode 100644 pkg/analyzer/headers_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index e7ca45c..6762439 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -252,8 +252,7 @@ components: pattern: '^[a-z0-9-]+$' description: Associated test ID (base32-encoded with hyphens) score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Overall deliverability score as percentage (0-100) @@ -298,36 +297,31 @@ components: - header_score properties: authentication_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: SPF/DKIM/DMARC score (in percentage) example: 28 spam_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: SpamAssassin score (in percentage) example: 15 blacklist_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Blacklist check score (in percentage) example: 20 content_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Content quality score (in percentage) example: 18 header_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Header quality score (in percentage) @@ -358,8 +352,7 @@ components: description: Check result status example: "pass" score: - type: number - format: float + type: integer description: Points contributed to total score example: 10 grade: diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 2cccf1b..03a1720 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -92,10 +92,6 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze 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") diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index dd082a5..80fa7f2 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -79,14 +79,6 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A }, nil } -// GetScoreSummaryText returns a human-readable score summary -func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { - if result == nil || result.Results == nil { - return "" - } - return a.generator.GetScoreSummaryText(result.Results) -} - // APIAdapter adapts the EmailAnalyzer to work with the API package // This adapter implements the interface expected by the API handler type APIAdapter struct { diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index d6fd600..eef44b1 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,6 +24,7 @@ package analyzer import ( "fmt" "regexp" + "slices" "strings" "git.happydns.org/happyDeliver/internal/api" @@ -190,14 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { 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 - } - } + result.Details = &part return result } @@ -221,17 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - // Extract details (action, policy, etc.) - var detailsParts []string - actionRe := regexp.MustCompile(`action=([^\s;]+)`) - if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { - detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, " ") - result.Details = &details - } + result.Details = &part return result } @@ -262,14 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { 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 - } - } + result.Details = &part return result } @@ -286,14 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { 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 - } - } + result.Details = &part return result } @@ -389,7 +359,7 @@ func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, // 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) { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { return false } } @@ -413,16 +383,6 @@ func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { 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 { @@ -447,8 +407,10 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe result.Result = api.AuthResultResult(resultStr) } + result.Details = &receivedSPF + // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { email := matches[1] if idx := strings.Index(email, "@"); idx != -1 { diff --git a/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go index 01298a0..f7cc15e 100644 --- a/pkg/analyzer/authentication_checks.go +++ b/pkg/analyzer/authentication_checks.go @@ -41,7 +41,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "SPF Record", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No SPF authentication result found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), @@ -59,7 +59,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "DKIM Signature", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No DKIM signature found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), @@ -75,7 +75,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "DMARC Policy", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No DMARC authentication result found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Implement DMARC policy for your domain"), @@ -106,37 +106,38 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check switch spf.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 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.Score = 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.Score = 50 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.Score = 50 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.Score = 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 { + if spf.Details != nil { + check.Details = spf.Details + } else if spf.Domain != nil { details := fmt.Sprintf("Domain: %s", *spf.Domain) check.Details = &details } @@ -153,34 +154,38 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i switch dkim.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 10 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.Score = 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.Score = 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 + if dkim.Details != nil { + check.Details = dkim.Details + } else { + 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 @@ -195,25 +200,27 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C switch dmarc.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 10 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.Score = 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.Score = 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 { + if dmarc.Details != nil { + check.Details = dmarc.Details + } else if dmarc.Domain != nil { details := fmt.Sprintf("Domain: %s", *dmarc.Domain) check.Details = &details } @@ -230,25 +237,27 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che switch bimi.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Score = 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.Score = 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.Score = 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 { + if bimi.Details != nil { + check.Details = bimi.Details + } else if bimi.Domain != nil { details := fmt.Sprintf("Domain: %s", *bimi.Domain) check.Details = &details } @@ -265,39 +274,43 @@ func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check switch arc.Result { case api.ARCResultResultPass: check.Status = api.CheckStatusPass - check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) + check.Score = 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.Score = 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.Score = 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) - } + check.Details = arc.Details + } else { + // 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 + 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 index ecd5832..0b03998 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -251,7 +251,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { name string spf *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "SPF pass", @@ -260,7 +260,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "SPF fail", @@ -269,7 +269,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "SPF softfail", @@ -278,7 +278,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, { name: "SPF neutral", @@ -287,7 +287,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, } @@ -319,7 +319,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { dkim *api.AuthResult index int expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "DKIM pass", @@ -330,7 +330,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "DKIM fail", @@ -341,7 +341,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "DKIM none", @@ -352,7 +352,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -383,7 +383,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { name string dmarc *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "DMARC pass", @@ -392,7 +392,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "DMARC fail", @@ -401,7 +401,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -432,7 +432,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { name string bimi *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "BIMI pass", @@ -441,7 +441,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score + expectedScore: 0, // BIMI doesn't contribute to score }, { name: "BIMI fail", @@ -450,7 +450,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, { name: "BIMI none", @@ -459,7 +459,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -494,7 +494,7 @@ func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string results *api.AuthenticationResults - expectedScore float32 + expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", @@ -509,7 +509,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 30.0, + expectedScore: 30, }, { name: "SPF and DKIM only", @@ -521,7 +521,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 20.0, + expectedScore: 20, }, { name: "SPF fail, DKIM pass", @@ -533,7 +533,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 10.0, + expectedScore: 10, }, { name: "SPF softfail", @@ -542,12 +542,12 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultSoftfail, }, }, - expectedScore: 5.0, + expectedScore: 5, }, { name: "No authentication", results: &api.AuthenticationResults{}, - expectedScore: 0.0, + expectedScore: 0, }, { name: "BIMI doesn't affect score", @@ -559,7 +559,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 10.0, // Only SPF counted, not BIMI + expectedScore: 10, // Only SPF counted, not BIMI }, } @@ -789,7 +789,7 @@ func TestGenerateARCCheck(t *testing.T) { name string arc *api.ARCResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "ARC pass", @@ -799,7 +799,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(true), }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // ARC doesn't contribute to score + expectedScore: 0, // ARC doesn't contribute to score }, { name: "ARC fail", @@ -809,7 +809,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(false), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, { name: "ARC none", @@ -819,7 +819,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(true), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 7c68323..872c75c 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -506,7 +506,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api if !results.HTMLValid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "HTML structure is invalid" if len(results.HTMLErrors) > 0 { @@ -516,7 +516,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering") } else { check.Status = api.CheckStatusPass - check.Score = 0.2 + check.Score = 2 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "HTML structure is valid" check.Advice = api.PtrTo("Your HTML is well-formed") @@ -551,7 +551,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec if brokenLinks > 0 { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 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") @@ -559,7 +559,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec check.Details = &details } else if warningLinks > 0 { check.Status = api.CheckStatusWarn - check.Score = 0.3 + check.Score = 3 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") @@ -567,7 +567,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.4 + check.Score = 4 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") @@ -600,7 +600,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che if noAltCount == len(results.Images) { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 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") @@ -608,7 +608,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che check.Details = &details } else if noAltCount > 0 { check.Status = api.CheckStatusWarn - check.Score = 0.2 + check.Score = 2 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") @@ -616,7 +616,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All images have alt attributes" check.Advice = api.PtrTo("Your images are properly tagged for accessibility") @@ -635,13 +635,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api. if !results.HasUnsubscribe { check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 0 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.Score = 3 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") @@ -661,7 +661,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) if consistency < 0.3 { check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 0 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") @@ -669,7 +669,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 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") @@ -692,7 +692,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C // Flag if more than 1 image per 100 characters (very image-heavy) if ratio > 10.0 { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 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") @@ -700,7 +700,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C check.Details = &details } else if ratio > 5.0 { check.Status = api.CheckStatusWarn - check.Score = 0.2 + check.Score = 2 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") @@ -708,7 +708,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 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") @@ -746,19 +746,19 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap } // GetContentScore calculates the content score (0-20 points) -func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { +func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int { if results == nil { - return 0.0 + return 0 } - var score float32 = 0.0 + var score int = 0 - // HTML validity (2 points) + // HTML validity (10 points) if results.HTMLValid { - score += 2.0 + score += 10 } - // Links (4 points) + // Links (20 points) if len(results.Links) > 0 { brokenLinks := 0 for _, link := range results.Links { @@ -767,14 +767,14 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if brokenLinks == 0 { - score += 4.0 + score += 20 } } else { // No links is neutral, give partial score - score += 2.0 + score += 10 } - // Images (3 points) + // Images (15 points) if len(results.Images) > 0 { noAltCount := 0 for _, img := range results.Images { @@ -783,47 +783,47 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if noAltCount == 0 { - score += 3.0 + score += 15 } else if noAltCount < len(results.Images) { - score += 1.5 + score += 7 } } else { // No images is neutral - score += 1.5 + score += 7 } - // Unsubscribe link (3 points) + // Unsubscribe link (15 points) if results.HasUnsubscribe { - score += 3.0 + score += 15 } - // Text consistency (3 points) + // Text consistency (15 points) if results.TextPlainRatio >= 0.3 { - score += 3.0 + score += 15 } - // Image ratio (3 points) + // Image ratio (15 points) if results.ImageTextRatio <= 5.0 { - score += 3.0 + score += 15 } else if results.ImageTextRatio <= 10.0 { - score += 1.5 + score += 7 } // Penalize suspicious URLs (deduct up to 5 points) if len(results.SuspiciousURLs) > 0 { - penalty := float32(len(results.SuspiciousURLs)) * 1.0 + penalty := len(results.SuspiciousURLs) if penalty > 5.0 { - penalty = 5.0 + penalty = 5 } score -= penalty } - // Ensure score is between 0 and 20 + // Ensure score is between 0 and 100 if score < 0 { score = 0 } - if score > 20.0 { - score = 20.0 + if score > 100 { + score = 100 } return score diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index c82d4a8..0a1c710 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -613,7 +613,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { name string results *ContentResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid HTML", @@ -621,7 +621,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { HTMLValid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.2, + expectedScore: 2, }, { name: "Invalid HTML", @@ -630,7 +630,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { HTMLErrors: []string{"Parse error"}, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -658,7 +658,7 @@ func TestGenerateLinkChecks(t *testing.T) { name string results *ContentResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "All links valid", @@ -669,7 +669,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, + expectedScore: 4, }, { name: "Broken links", @@ -679,7 +679,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Links with warnings", @@ -689,7 +689,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.3, + expectedScore: 3, }, { name: "No links", @@ -927,14 +927,14 @@ func TestGetContentScore(t *testing.T) { tests := []struct { name string results *ContentResults - minScore float32 - maxScore float32 + minScore int + maxScore int }{ { name: "Nil results", results: nil, - minScore: 0.0, - maxScore: 0.0, + minScore: 0, + maxScore: 0, }, { name: "Perfect content", @@ -946,8 +946,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.8, ImageTextRatio: 3.0, }, - minScore: 18.0, - maxScore: 20.0, + minScore: 90, + maxScore: 100, }, { name: "Poor content", @@ -960,8 +960,8 @@ func TestGetContentScore(t *testing.T) { ImageTextRatio: 15.0, SuspiciousURLs: []string{"url1", "url2"}, }, - minScore: 0.0, - maxScore: 5.0, + minScore: 0, + maxScore: 25, }, { name: "Average content", @@ -973,8 +973,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.5, ImageTextRatio: 4.0, }, - minScore: 10.0, - maxScore: 18.0, + minScore: 50, + maxScore: 90, }, } @@ -988,13 +988,13 @@ func TestGetContentScore(t *testing.T) { t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } - // Ensure score is capped at 20.0 - if score > 20.0 { - t.Errorf("Score %v exceeds maximum of 20.0", score) + // Ensure score is capped at 100 + if score > 100 { + t.Errorf("Score %v exceeds maximum of 100", score) } // Ensure score is not negative - if score < 0.0 { + if score < 0 { t.Errorf("Score %v is negative", score) } }) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 9a6d26f..1a03a99 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -521,7 +521,7 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { // BIMI record check (optional) if results.BIMIRecord != nil { - checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) + checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord)) } return checks @@ -536,7 +536,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.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityCritical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { @@ -547,7 +547,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { check.Advice = api.PtrTo("Configure MX records for your domain to receive email") } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) @@ -572,25 +572,25 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { } if !spf.Valid { - // If no record exists at all, it's a failure if spf.Record == "" { + // If no record exists at all, it's a failure check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = spf.Error - check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Severity = api.PtrTo(api.CheckSeverityMedium) 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 + // If record exists but is invalid, it's a failure + check.Status = api.CheckStatusFail + check.Score = 5 check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = "Valid SPF record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &spf.Record @@ -609,7 +609,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { if !dkim.Valid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") @@ -617,7 +617,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = "Valid DKIM record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) @@ -637,13 +637,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { if !dmarc.Valid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = dmarc.Error check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &dmarc.Record @@ -669,7 +669,7 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { } // generateBIMICheck creates a check for BIMI records -func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { +func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) api.Check { check := api.Check{ Category: api.Dns, Name: "BIMI Record", @@ -679,14 +679,18 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { // BIMI is optional, so missing record is just informational if bimi.Record == "" { check.Status = api.CheckStatusInfo - check.Score = 0.0 + check.Score = 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)") + if dmarc.Policy != "quarantine" && dmarc.Policy != "reject" { + 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 { + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients.") + } } else { // If record exists but is invalid check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 5 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=)") @@ -694,7 +698,7 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { } } else { check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Score = 100 // BIMI doesn't contribute to score (branding feature) check.Message = "Valid BIMI record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 12a6bd0..750c620 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -305,7 +305,7 @@ func TestGenerateMXCheck(t *testing.T) { name string results *DNSResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid MX records", @@ -317,7 +317,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "No MX records", @@ -328,7 +328,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "MX lookup failed", @@ -339,7 +339,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -367,7 +367,7 @@ func TestGenerateSPFCheck(t *testing.T) { name string spf *SPFRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid SPF", @@ -376,7 +376,7 @@ func TestGenerateSPFCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Invalid SPF", @@ -386,7 +386,7 @@ func TestGenerateSPFCheck(t *testing.T) { Error: "SPF record appears malformed", }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, { name: "No SPF record", @@ -395,7 +395,7 @@ func TestGenerateSPFCheck(t *testing.T) { Error: "No SPF record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -423,7 +423,7 @@ func TestGenerateDKIMCheck(t *testing.T) { name string dkim *DKIMRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid DKIM", @@ -434,7 +434,7 @@ func TestGenerateDKIMCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Invalid DKIM", @@ -445,7 +445,7 @@ func TestGenerateDKIMCheck(t *testing.T) { Error: "No DKIM record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -476,7 +476,7 @@ func TestGenerateDMARCCheck(t *testing.T) { name string dmarc *DMARCRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid DMARC - reject", @@ -486,7 +486,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Valid DMARC - quarantine", @@ -496,7 +496,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Valid DMARC - none", @@ -506,7 +506,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "No DMARC record", @@ -515,7 +515,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Error: "No DMARC record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -738,7 +738,7 @@ func TestGenerateBIMICheck(t *testing.T) { name string bimi *BIMIRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid BIMI with logo only", @@ -750,7 +750,7 @@ func TestGenerateBIMICheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score + expectedScore: 0, // BIMI doesn't contribute to score }, { name: "Valid BIMI with VMC", @@ -763,7 +763,7 @@ func TestGenerateBIMICheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, + expectedScore: 0, }, { name: "No BIMI record (optional)", @@ -774,7 +774,7 @@ func TestGenerateBIMICheck(t *testing.T) { Error: "No BIMI record found", }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Invalid BIMI record", @@ -786,7 +786,7 @@ func TestGenerateBIMICheck(t *testing.T) { Error: "BIMI record appears malformed", }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go new file mode 100644 index 0000000..7fa252a --- /dev/null +++ b/pkg/analyzer/headers.go @@ -0,0 +1,303 @@ +// 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" +) + +// HeaderAnalyzer analyzes email header quality and structure +type HeaderAnalyzer struct{} + +// NewHeaderAnalyzer creates a new header analyzer +func NewHeaderAnalyzer() *HeaderAnalyzer { + return &HeaderAnalyzer{} +} + +// calculateHeaderScore evaluates email structural quality +func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int { + if email == nil { + return 0 + } + + score := 0 + requiredHeaders := 0 + presentHeaders := 0 + + // Check required headers (RFC 5322) + headers := map[string]bool{ + "From": false, + "Date": false, + "Message-ID": false, + } + + for header := range headers { + requiredHeaders++ + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + headers[header] = true + presentHeaders++ + } + } + + // Score based on required headers (40 points) + if presentHeaders == requiredHeaders { + score += 40 + } else { + score += int(40 * (float32(presentHeaders) / float32(requiredHeaders))) + } + + // Check recommended headers (30 points) + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + recommendedPresent := 0 + for _, header := range recommendedHeaders { + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + recommendedPresent++ + } + } + score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))) + + // Check for proper MIME structure (20 points) + if len(email.Parts) > 0 { + score += 20 + } + + // Check Message-ID format (10 point) + if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { + if h.isValidMessageID(messageID) { + score += 10 + } + } + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + + return score +} + +// isValidMessageID checks if a Message-ID has proper format +func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { + // Basic check: should be in format <...@...> + if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { + return false + } + + // Remove angle brackets + messageID = strings.TrimPrefix(messageID, "<") + messageID = strings.TrimSuffix(messageID, ">") + + // Should contain @ symbol + if !strings.Contains(messageID, "@") { + return false + } + + parts := strings.Split(messageID, "@") + if len(parts) != 2 { + return false + } + + // Both parts should be non-empty + return len(parts[0]) > 0 && len(parts[1]) > 0 +} + +// GenerateHeaderChecks creates checks for email header quality +func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check { + var checks []api.Check + + if email == nil { + return checks + } + + // Required headers check + checks = append(checks, h.generateRequiredHeadersCheck(email)) + + // Recommended headers check + checks = append(checks, h.generateRecommendedHeadersCheck(email)) + + // Message-ID check + checks = append(checks, h.generateMessageIDCheck(email)) + + // MIME structure check + checks = append(checks, h.generateMIMEStructureCheck(email)) + + return checks +} + +// generateRequiredHeadersCheck checks for required RFC 5322 headers +func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Required Headers", + } + + requiredHeaders := []string{"From", "Date", "Message-ID"} + missing := []string{} + + for _, header := range requiredHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 4.0 + check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) + 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.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityCritical) + check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } + + return check +} + +// generateRecommendedHeadersCheck checks for recommended headers +func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Recommended Headers", + } + + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + missing := []string{} + + for _, header := range recommendedHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 30 + check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) + 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 = 15 + check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } else { + check.Status = api.CheckStatusWarn + check.Score = 0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Message = "Missing all recommended headers" + check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") + } + + return check +} + +// generateMessageIDCheck validates Message-ID header +func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Message-ID Format", + } + + messageID := email.GetHeaderValue("Message-ID") + + if messageID == "" { + check.Status = api.CheckStatusFail + check.Score = 0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Message = "Message-ID header is missing" + check.Advice = api.PtrTo("Add a unique Message-ID header to your email") + } else if !h.isValidMessageID(messageID) { + check.Status = api.CheckStatusWarn + check.Score = 5 + check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) + 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 = 10 + check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = "Message-ID is properly formatted" + check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") + check.Details = &messageID + } + + return check +} + +// generateMIMEStructureCheck validates MIME structure +func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "MIME Structure", + } + + if len(email.Parts) == 0 { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Message = "No MIME parts detected" + check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") + } else { + check.Status = api.CheckStatusPass + check.Score = 2.0 + check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) + check.Advice = api.PtrTo("Your email has proper MIME structure") + + // Add details about parts + partTypes := []string{} + for _, part := range email.Parts { + if part.ContentType != "" { + partTypes = append(partTypes, part.ContentType) + } + } + if len(partTypes) > 0 { + details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) + check.Details = &details + } + } + + return check +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go new file mode 100644 index 0000000..8594f7f --- /dev/null +++ b/pkg/analyzer/headers_test.go @@ -0,0 +1,324 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestCalculateHeaderScore(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minScore int + maxScore int + }{ + { + name: "Nil email", + email: nil, + minScore: 0, + maxScore: 0, + }, + { + name: "Perfect headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0, + maxScore: 40, + }, + { + name: "Required only, no recommended", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 40, + maxScore: 80, + }, + { + name: "Invalid Message-ID format", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "invalid-message-id", + "Subject": "Test", + "To": "recipient@example.com", + "Reply-To": "reply@example.com", + }), + MessageID: "invalid-message-id", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := analyzer.calculateHeaderScore(tt.email) + if score < tt.minScore || score > tt.maxScore { + t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + }) + } +} + +func TestGenerateRequiredHeadersCheck(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + expectedStatus api.CheckStatus + expectedScore int + }{ + { + name: "All required headers present", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + From: &mail.Address{Address: "sender@example.com"}, + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 40, + }, + { + name: "Missing all required headers", + email: &EmailMessage{ + Header: make(mail.Header), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0, + }, + { + name: "Missing some required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + }), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateRequiredHeadersCheck(tt.email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMessageIDCheck(t *testing.T) { + tests := []struct { + name string + messageID string + expectedStatus api.CheckStatus + }{ + { + name: "Valid Message-ID", + messageID: "", + expectedStatus: api.CheckStatusPass, + }, + { + name: "Invalid Message-ID format", + messageID: "invalid-message-id", + expectedStatus: api.CheckStatusWarn, + }, + { + name: "Missing Message-ID", + messageID: "", + expectedStatus: api.CheckStatusFail, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Message-ID": tt.messageID, + }), + } + + check := analyzer.generateMessageIDCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMIMEStructureCheck(t *testing.T) { + tests := []struct { + name string + parts []MessagePart + expectedStatus api.CheckStatus + }{ + { + name: "With MIME parts", + parts: []MessagePart{ + {ContentType: "text/plain", Content: "test"}, + {ContentType: "text/html", Content: "

    test

    "}, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "No MIME parts", + parts: []MessagePart{}, + expectedStatus: api.CheckStatusWarn, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: make(mail.Header), + Parts: tt.parts, + } + + check := analyzer.generateMIMEStructureCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + }) + } +} + +func TestGenerateHeaderChecks(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minChecks int + }{ + { + name: "Nil email", + email: nil, + minChecks: 0, + }, + { + name: "Complete email", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minChecks: 4, // Required, Recommended, Message-ID, MIME + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateHeaderChecks(tt.email) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the Headers category + for _, check := range checks { + if check.Category != api.Headers { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) + } + } + }) + } +} + +// Helper function to create mail.Header with specific fields +func createHeaderWithFields(fields map[string]string) mail.Header { + header := make(mail.Header) + for key, value := range fields { + if value != "" { + // Use canonical MIME header key format + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + header[canonicalKey] = []string{value} + } + } + return header +} diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 3904c6f..2084ea5 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -238,29 +238,14 @@ func (r *RBLChecker) reverseIP(ipStr string) string { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// GetBlacklistScore calculates the blacklist contribution to deliverability (0-20 points) -// Scoring: -// - Not listed on any RBL: 20 points (excellent) -// - Listed on 1 RBL: 10 points (warning) -// - Listed on 2-3 RBLs: 5 points (poor) -// - Listed on 4+ RBLs: 0 points (critical) -func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { +// GetBlacklistScore calculates the blacklist contribution to deliverability +func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt - return 20.0 + return 100 } - listedCount := results.ListedCount - - if listedCount == 0 { - return 20.0 - } else if listedCount == 1 { - return 10.0 - } else if listedCount <= 3 { - return 5.0 - } - - return 0.0 + return 100 - results.ListedCount*100/len(r.RBLs) } // GenerateRBLChecks generates check results for RBL analysis @@ -277,8 +262,8 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Category: api.Blacklist, Name: "RBL Check", Status: api.CheckStatusWarn, - Score: 10.0, - Grade: ScoreToCheckGrade((10.0 / 20.0) * 100), + Score: 50, + Grade: ScoreToCheckGrade(50), Message: "No public IP addresses found to check", Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), @@ -310,7 +295,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { score := r.GetBlacklistScore(results) check.Score = score - check.Grade = ScoreToCheckGrade((score / 20.0) * 100) + check.Grade = ScoreToCheckGrade(score) totalChecks := len(results.Checks) listedCount := results.ListedCount @@ -352,8 +337,8 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), Status: api.CheckStatusFail, - Score: 0.0, - Grade: ScoreToCheckGrade(0.0), + Score: 0, + Grade: ScoreToCheckGrade(0), } check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 0bf8c0e..c2bac11 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -267,19 +267,19 @@ func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string results *RBLResults - expectedScore float32 + expectedScore int }{ { name: "Nil results", results: nil, - expectedScore: 20.0, + expectedScore: 200, }, { name: "No IPs checked", results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Not listed on any RBL", @@ -287,7 +287,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Listed on 1 RBL", @@ -295,7 +295,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 10.0, + expectedScore: 100, }, { name: "Listed on 2 RBLs", @@ -303,7 +303,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 3 RBLs", @@ -311,7 +311,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 4+ RBLs", @@ -319,7 +319,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -340,7 +340,7 @@ func TestGenerateSummaryCheck(t *testing.T) { name string results *RBLResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Not listed", @@ -350,7 +350,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), // 6 default RBLs }, expectedStatus: api.CheckStatusPass, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Listed on 1 RBL", @@ -360,7 +360,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 10.0, + expectedScore: 100, }, { name: "Listed on 2 RBLs", @@ -370,7 +370,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 4+ RBLs", @@ -380,7 +380,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 79799b9..6d5522b 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -36,6 +36,7 @@ type ReportGenerator struct { dnsAnalyzer *DNSAnalyzer rblChecker *RBLChecker contentAnalyzer *ContentAnalyzer + headerAnalyzer *HeaderAnalyzer scorer *DeliverabilityScorer } @@ -51,6 +52,7 @@ func NewReportGenerator( dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), rblChecker: NewRBLChecker(dnsTimeout, rbls), contentAnalyzer: NewContentAnalyzer(httpTimeout), + headerAnalyzer: NewHeaderAnalyzer(), scorer: NewDeliverabilityScorer(), } } @@ -63,7 +65,6 @@ type AnalysisResults struct { DNS *DNSResults RBL *RBLResults Content *ContentResults - Score *ScoringResult } // AnalyzeEmail performs complete email analysis @@ -79,15 +80,6 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { results.RBL = r.rblChecker.CheckEmail(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) - // Calculate overall score - results.Score = r.scorer.CalculateScore( - results.Authentication, - results.SpamAssassin, - results.RBL, - results.Content, - email, - ) - return results } @@ -99,20 +91,9 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report := &api.Report{ Id: utils.UUIDToBase32(reportID), TestId: utils.UUIDToBase32(testID), - Score: results.Score.OverallScore, - Grade: ScoreToReportGrade(results.Score.OverallScore), CreatedAt: now, } - // Build score summary - report.Summary = &api.ScoreSummary{ - AuthenticationScore: results.Score.AuthScore, - SpamScore: results.Score.SpamScore, - BlacklistScore: results.Score.BlacklistScore, - ContentScore: results.Score.ContentScore, - HeaderScore: results.Score.HeaderScore, - } - // Collect all checks from different analyzers checks := []api.Check{} @@ -147,11 +128,40 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } // Header checks - headerChecks := r.scorer.GenerateHeaderChecks(results.Email) + headerChecks := r.headerAnalyzer.GenerateHeaderChecks(results.Email) checks = append(checks, headerChecks...) report.Checks = checks + // Summarize scores by category + categoryCounts := make(map[api.CheckCategory]int) + categoryTotals := make(map[api.CheckCategory]int) + + for _, check := range checks { + if check.Status == "info" { + continue + } + + categoryCounts[check.Category]++ + categoryTotals[check.Category] += check.Score + } + + // Calculate mean scores for each category + calcCategoryScore := func(category api.CheckCategory) int { + if count := categoryCounts[category]; count > 0 { + return categoryTotals[category] / count + } + return 0 + } + + report.Summary = &api.ScoreSummary{ + AuthenticationScore: calcCategoryScore(api.Authentication), + BlacklistScore: calcCategoryScore(api.Blacklist), + ContentScore: calcCategoryScore(api.Content), + HeaderScore: calcCategoryScore(api.Headers), + SpamScore: calcCategoryScore(api.Spam), + } + // Add authentication results report.Authentication = results.Authentication @@ -202,6 +212,30 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report.RawHeaders = &results.Email.RawHeaders } + // Calculate overall score as mean of all category scores + categoryScores := []int{ + report.Summary.AuthenticationScore, + report.Summary.BlacklistScore, + report.Summary.ContentScore, + report.Summary.HeaderScore, + report.Summary.SpamScore, + } + + var totalScore int + var categoryCount int + for _, score := range categoryScores { + totalScore += score + categoryCount++ + } + + if categoryCount > 0 { + report.Score = totalScore / categoryCount + } else { + report.Score = 0 + } + + report.Grade = ScoreToReportGrade(report.Score) + return report } @@ -330,21 +364,3 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { return raw } - -// GetRecommendations returns actionable recommendations based on the score -func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { - if results == nil || results.Score == nil { - return []string{} - } - - return results.Score.Recommendations -} - -// GetScoreSummaryText returns a human-readable score summary -func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { - if results == nil || results.Score == nil { - return "" - } - - return r.scorer.GetScoreSummary(results.Score) -} diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 0dd7e8c..85edcd2 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -336,13 +336,13 @@ func TestGetRecommendations(t *testing.T) { name: "Results with score", results: &AnalysisResults{ Score: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 1.0, - BlacklistScore: 1.5, - ContentScore: 0.5, - HeaderScore: 0.5, + OverallScore: 50, + Grade: ScoreToReportGrade(50), + AuthScore: 15, + SpamScore: 10, + BlacklistScore: 15, + ContentScore: 5, + HeaderScore: 5, Recommendations: []string{ "Improve authentication", "Fix content issues", @@ -381,19 +381,19 @@ func TestGetScoreSummaryText(t *testing.T) { name: "Results with score", results: &AnalysisResults{ Score: &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, + OverallScore: 85, + Grade: ScoreToReportGrade(85), + AuthScore: 25, + SpamScore: 18, + BlacklistScore: 20, + ContentScore: 15, + HeaderScore: 7, CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, + "Authentication": {Score: 25, Status: "Pass"}, + "Spam Filters": {Score: 18, Status: "Pass"}, + "Blacklists": {Score: 20, Status: "Pass"}, + "Content Quality": {Score: 15, Status: "Warn"}, + "Email Structure": {Score: 7, Status: "Warn"}, }, }, }, diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 7d5184f..6db6e0c 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -22,15 +22,11 @@ package analyzer import ( - "fmt" - "strings" - "time" - "git.happydns.org/happyDeliver/internal/api" ) // ScoreToGrade converts a percentage score (0-100) to a letter grade -func ScoreToGrade(score float32) string { +func ScoreToGrade(score int) string { switch { case score >= 97: return "A+" @@ -50,12 +46,12 @@ func ScoreToGrade(score float32) string { } // ScoreToCheckGrade converts a percentage score to an api.CheckGrade -func ScoreToCheckGrade(score float32) api.CheckGrade { +func ScoreToCheckGrade(score int) api.CheckGrade { return api.CheckGrade(ScoreToGrade(score)) } // ScoreToReportGrade converts a percentage score to an api.ReportGrade -func ScoreToReportGrade(score float32) api.ReportGrade { +func ScoreToReportGrade(score int) api.ReportGrade { return api.ReportGrade(ScoreToGrade(score)) } @@ -66,520 +62,3 @@ type DeliverabilityScorer struct{} func NewDeliverabilityScorer() *DeliverabilityScorer { return &DeliverabilityScorer{} } - -// ScoringResult represents the complete scoring result -type ScoringResult struct { - OverallScore float32 - Rating string // Excellent, Good, Fair, Poor, Critical - AuthScore float32 - SpamScore float32 - BlacklistScore float32 - ContentScore float32 - HeaderScore float32 - Recommendations []string - CategoryBreakdown map[string]CategoryScore -} - -// CategoryScore represents score breakdown for a category -type CategoryScore struct { - Score float32 - MaxScore float32 - Percentage float32 - Status string // Pass, Warn, Fail -} - -// CalculateScore computes the overall deliverability score from all analyzers -func (s *DeliverabilityScorer) CalculateScore( - authResults *api.AuthenticationResults, - spamResult *SpamAssassinResult, - rblResults *RBLResults, - contentResults *ContentResults, - email *EmailMessage, -) *ScoringResult { - result := &ScoringResult{ - CategoryBreakdown: make(map[string]CategoryScore), - Recommendations: []string{}, - } - - // Calculate individual scores - result.AuthScore = s.GetAuthenticationScore(authResults) - - spamAnalyzer := NewSpamAssassinAnalyzer() - result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) - - rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs) - result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults) - - contentAnalyzer := NewContentAnalyzer(10 * time.Second) - result.ContentScore = contentAnalyzer.GetContentScore(contentResults) - - // Calculate header quality score - result.HeaderScore = s.calculateHeaderScore(email) - - // Calculate overall score (out of 100) - result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - - // Ensure score is within bounds - if result.OverallScore > 100.0 { - result.OverallScore = 100.0 - } - if result.OverallScore < 0.0 { - result.OverallScore = 0.0 - } - - // Determine rating - result.Rating = s.determineRating(result.OverallScore) - - // Build category breakdown - result.CategoryBreakdown["Authentication"] = CategoryScore{ - Score: result.AuthScore, - MaxScore: 30.0, - Percentage: result.AuthScore, - Status: s.getCategoryStatus(result.AuthScore, 30.0), - } - - result.CategoryBreakdown["Spam Filters"] = CategoryScore{ - Score: result.SpamScore, - MaxScore: 20.0, - Percentage: result.SpamScore, - Status: s.getCategoryStatus(result.SpamScore, 20.0), - } - - result.CategoryBreakdown["Blacklists"] = CategoryScore{ - Score: result.BlacklistScore, - MaxScore: 20.0, - Percentage: result.BlacklistScore, - Status: s.getCategoryStatus(result.BlacklistScore, 20.0), - } - - result.CategoryBreakdown["Content Quality"] = CategoryScore{ - Score: result.ContentScore, - MaxScore: 20.0, - Percentage: result.ContentScore, - Status: s.getCategoryStatus(result.ContentScore, 20.0), - } - - result.CategoryBreakdown["Email Structure"] = CategoryScore{ - Score: result.HeaderScore, - MaxScore: 10.0, - Percentage: result.HeaderScore, - Status: s.getCategoryStatus(result.HeaderScore, 10.0), - } - - // Generate recommendations - result.Recommendations = s.generateRecommendations(result) - - return result -} - -// calculateHeaderScore evaluates email structural quality (0-10 points) -func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { - if email == nil { - return 0.0 - } - - score := float32(0.0) - requiredHeaders := 0 - presentHeaders := 0 - - // Check required headers (RFC 5322) - headers := map[string]bool{ - "From": false, - "Date": false, - "Message-ID": false, - } - - for header := range headers { - requiredHeaders++ - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - headers[header] = true - presentHeaders++ - } - } - - // Score based on required headers (4 points) - if presentHeaders == requiredHeaders { - score += 4.0 - } else { - score += 4.0 * (float32(presentHeaders) / float32(requiredHeaders)) - } - - // Check recommended headers (3 points) - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - recommendedPresent := 0 - for _, header := range recommendedHeaders { - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - recommendedPresent++ - } - } - score += 3.0 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - - // Check for proper MIME structure (2 points) - if len(email.Parts) > 0 { - score += 2.0 - } - - // Check Message-ID format (1 point) - if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { - if s.isValidMessageID(messageID) { - score += 1.0 - } - } - - // Ensure score doesn't exceed 10.0 - if score > 10.0 { - score = 10.0 - } - - return score -} - -// isValidMessageID checks if a Message-ID has proper format -func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { - // Basic check: should be in format <...@...> - if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { - return false - } - - // Remove angle brackets - messageID = strings.TrimPrefix(messageID, "<") - messageID = strings.TrimSuffix(messageID, ">") - - // Should contain @ symbol - if !strings.Contains(messageID, "@") { - return false - } - - parts := strings.Split(messageID, "@") - if len(parts) != 2 { - return false - } - - // Both parts should be non-empty - return len(parts[0]) > 0 && len(parts[1]) > 0 -} - -// determineRating determines the rating based on overall score (0-100) -func (s *DeliverabilityScorer) determineRating(score float32) string { - switch { - case score >= 90.0: - return "Excellent" - case score >= 70.0: - return "Good" - case score >= 50.0: - return "Fair" - case score >= 30.0: - return "Poor" - default: - return "Critical" - } -} - -// getCategoryStatus determines status for a category -func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string { - percentage := (score / maxScore) * 100 - - switch { - case percentage >= 80.0: - return "Pass" - case percentage >= 50.0: - return "Warn" - default: - return "Fail" - } -} - -// generateRecommendations creates actionable recommendations based on scores -func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { - var recommendations []string - - // Authentication recommendations (0-30 points) - if result.AuthScore < 20.0 { - recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") - } else if result.AuthScore < 30.0 { - recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") - } - - // Spam recommendations (0-20 points) - if result.SpamScore < 10.0 { - recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") - } else if result.SpamScore < 15.0 { - recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") - } - - // Blacklist recommendations (0-20 points) - if result.BlacklistScore < 10.0 { - recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") - } else if result.BlacklistScore < 20.0 { - recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") - } - - // Content recommendations (0-20 points) - if result.ContentScore < 10.0 { - recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") - } else if result.ContentScore < 15.0 { - recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") - } - - // Header recommendations (0-10 points) - if result.HeaderScore < 5.0 { - recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") - } else if result.HeaderScore < 10.0 { - recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") - } - - // Overall recommendations based on rating - if result.Rating == "Excellent" { - recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices") - } else if result.Rating == "Critical" { - recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam") - } - - return recommendations -} - -// GenerateHeaderChecks creates checks for email header quality -func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check { - var checks []api.Check - - if email == nil { - return checks - } - - // Required headers check - checks = append(checks, s.generateRequiredHeadersCheck(email)) - - // Recommended headers check - checks = append(checks, s.generateRecommendedHeadersCheck(email)) - - // Message-ID check - checks = append(checks, s.generateMessageIDCheck(email)) - - // MIME structure check - checks = append(checks, s.generateMIMEStructureCheck(email)) - - return checks -} - -// generateRequiredHeadersCheck checks for required RFC 5322 headers -func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Required Headers", - } - - requiredHeaders := []string{"From", "Date", "Message-ID"} - missing := []string{} - - for _, header := range requiredHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 4.0 - check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) - 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.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } - - return check -} - -// generateRecommendedHeadersCheck checks for recommended headers -func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Recommended Headers", - } - - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - missing := []string{} - - for _, header := range recommendedHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 3.0 - check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) - 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 = 1.5 - check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } else { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Missing all recommended headers" - check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") - } - - return check -} - -// generateMessageIDCheck validates Message-ID header -func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Message-ID Format", - } - - messageID := email.GetHeaderValue("Message-ID") - - if messageID == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Message = "Message-ID header is missing" - check.Advice = api.PtrTo("Add a unique Message-ID header to your email") - } else if !s.isValidMessageID(messageID) { - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) - 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 = 1.0 - check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "Message-ID is properly formatted" - check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") - check.Details = &messageID - } - - return check -} - -// generateMIMEStructureCheck validates MIME structure -func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "MIME Structure", - } - - if len(email.Parts) == 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = "No MIME parts detected" - check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") - } else { - check.Status = api.CheckStatusPass - check.Score = 2.0 - check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) - check.Advice = api.PtrTo("Your email has proper MIME structure") - - // Add details about parts - partTypes := []string{} - for _, part := range email.Parts { - if part.ContentType != "" { - partTypes = append(partTypes, part.ContentType) - } - } - if len(partTypes) > 0 { - details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) - check.Details = &details - } - } - - return check -} - -// GetScoreSummary generates a human-readable summary of the score -func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { - var summary strings.Builder - - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/100 (%s) - Grade: %s\n\n", result.OverallScore, result.Rating, ScoreToGrade(result.OverallScore))) - summary.WriteString("Category Breakdown:\n") - summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/30.0 (%.0f%%) - %s\n", - result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) - summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/20.0 (%.0f%%) - %s\n", - result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) - summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/20.0 (%.0f%%) - %s\n", - result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) - summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/20.0 (%.0f%%) - %s\n", - result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) - summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/10.0 (%.0f%%) - %s\n", - result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) - - if len(result.Recommendations) > 0 { - summary.WriteString("\nRecommendations:\n") - for _, rec := range result.Recommendations { - summary.WriteString(fmt.Sprintf(" %s\n", rec)) - } - } - - return summary.String() -} - -// GetAuthenticationScore calculates the authentication score (0-30 points) -func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { - var score float32 = 0.0 - - // SPF: 10 points for pass, 5 for neutral/softfail, 0 for fail - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 10.0 - case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 5.0 - } - } - - // DKIM: 10 points for at least one pass - if results.Dkim != nil && len(*results.Dkim) > 0 { - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - score += 10.0 - break - } - } - } - - // DMARC: 10 points for pass - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 10.0 - } - } - - // Cap at 30 points maximum - if score > 30.0 { - score = 30.0 - } - - return score -} diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go index b4c756a..e464432 100644 --- a/pkg/analyzer/scoring_test.go +++ b/pkg/analyzer/scoring_test.go @@ -22,9 +22,6 @@ package analyzer import ( - "net/mail" - "net/textproto" - "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -97,153 +94,6 @@ func TestIsValidMessageID(t *testing.T) { } } -func TestCalculateHeaderScore(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minScore float32 - maxScore float32 - }{ - { - name: "Nil email", - email: nil, - minScore: 0.0, - maxScore: 0.0, - }, - { - name: "Perfect headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 7.0, - maxScore: 10.0, - }, - { - name: "Missing required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Subject": "Test", - }), - }, - minScore: 0.0, - maxScore: 4.0, - }, - { - name: "Required only, no recommended", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 4.0, - maxScore: 8.0, - }, - { - name: "Invalid Message-ID format", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "invalid-message-id", - "Subject": "Test", - "To": "recipient@example.com", - "Reply-To": "reply@example.com", - }), - MessageID: "invalid-message-id", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 7.0, - maxScore: 10.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := scorer.calculateHeaderScore(tt.email) - if score < tt.minScore || score > tt.maxScore { - t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - }) - } -} - -func TestDetermineRating(t *testing.T) { - tests := []struct { - name string - score float32 - expected string - }{ - {name: "Excellent - 10.0", score: 100.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 95.0, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 90.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 85.0, expected: "Good"}, - {name: "Good - 7.0", score: 70.0, expected: "Good"}, - {name: "Fair - 6.5", score: 65.0, expected: "Fair"}, - {name: "Fair - 5.0", score: 50.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 45.0, expected: "Poor"}, - {name: "Poor - 3.0", score: 30.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 25.0, expected: "Critical"}, - {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.determineRating(tt.score) - if result != tt.expected { - t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected) - } - }) - } -} - -func TestGetCategoryStatus(t *testing.T) { - tests := []struct { - name string - score float32 - maxScore float32 - expected string - }{ - {name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"}, - {name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"}, - {name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"}, - {name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"}, - {name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.getCategoryStatus(tt.score, tt.maxScore) - if result != tt.expected { - t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected) - } - }) - } -} - func TestCalculateScore(t *testing.T) { tests := []struct { name string @@ -252,9 +102,9 @@ func TestCalculateScore(t *testing.T) { rblResults *RBLResults contentResults *ContentResults email *EmailMessage - minScore float32 - maxScore float32 - expectedRating string + minScore int + maxScore int + expectedGrade string }{ { name: "Perfect email", @@ -294,9 +144,9 @@ func TestCalculateScore(t *testing.T) { MessageID: "", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 90.0, - maxScore: 100.0, - expectedRating: "Excellent", + minScore: 90.0, + maxScore: 100.0, + expectedGrade: "A+", }, { name: "Poor email - auth issues", @@ -329,9 +179,9 @@ func TestCalculateScore(t *testing.T) { "From": "sender@example.com", }), }, - minScore: 0.0, - maxScore: 50.0, - expectedRating: "Poor", + minScore: 0.0, + maxScore: 50.0, + expectedGrade: "C", }, { name: "Average email", @@ -366,9 +216,9 @@ func TestCalculateScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 60.0, - maxScore: 90.0, - expectedRating: "Good", + minScore: 60.0, + maxScore: 90.0, + expectedGrade: "A", }, } @@ -394,8 +244,8 @@ func TestCalculateScore(t *testing.T) { } // Check rating - if result.Rating != tt.expectedRating { - t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) + if result.Grade != api.ReportGrade(tt.expectedGrade) { + t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade) } // Verify score is within bounds @@ -409,354 +259,16 @@ func TestCalculateScore(t *testing.T) { } // Verify recommendations exist - if len(result.Recommendations) == 0 && result.Rating != "Excellent" { + if len(result.Recommendations) == 0 && result.Grade != "A+" { t.Error("Expected recommendations for non-excellent rating") } // Verify category scores add up to overall score totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 { - t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)", + if totalCategoryScore != result.OverallScore { + t.Errorf("Category scores sum (%d) doesn't match overall score (%d)", totalCategoryScore, result.OverallScore) } }) } } - -func TestGenerateRecommendations(t *testing.T) { - tests := []struct { - name string - result *ScoringResult - expectedMinCount int - shouldContainKeyword string - }{ - { - name: "Excellent - minimal recommendations", - result: &ScoringResult{ - OverallScore: 9.5, - Rating: "Excellent", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 2.0, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "Excellent", - }, - { - name: "Critical - many recommendations", - result: &ScoringResult{ - OverallScore: 1.0, - Rating: "Critical", - AuthScore: 0.5, - SpamScore: 0.0, - BlacklistScore: 0.0, - ContentScore: 0.3, - HeaderScore: 0.2, - }, - expectedMinCount: 5, - shouldContainKeyword: "Critical", - }, - { - name: "Poor authentication", - result: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "authentication", - }, - { - name: "Blacklist issues", - result: &ScoringResult{ - OverallScore: 4.0, - Rating: "Poor", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 0.5, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "blacklist", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recommendations := scorer.generateRecommendations(tt.result) - - if len(recommendations) < tt.expectedMinCount { - t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount) - } - - // Check if expected keyword appears in any recommendation - found := false - for _, rec := range recommendations { - if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) { - found = true - break - } - } - - if !found { - t.Errorf("No recommendation contains keyword %q. Recommendations: %v", - tt.shouldContainKeyword, recommendations) - } - }) - } -} - -func TestGenerateRequiredHeadersCheck(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "All required headers present", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - From: &mail.Address{Address: "sender@example.com"}, - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 4.0, - }, - { - name: "Missing all required headers", - email: &EmailMessage{ - Header: make(mail.Header), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "Missing some required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := scorer.generateRequiredHeadersCheck(tt.email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMessageIDCheck(t *testing.T) { - tests := []struct { - name string - messageID string - expectedStatus api.CheckStatus - }{ - { - name: "Valid Message-ID", - messageID: "", - expectedStatus: api.CheckStatusPass, - }, - { - name: "Invalid Message-ID format", - messageID: "invalid-message-id", - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Missing Message-ID", - messageID: "", - expectedStatus: api.CheckStatusFail, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Message-ID": tt.messageID, - }), - } - - check := scorer.generateMessageIDCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMIMEStructureCheck(t *testing.T) { - tests := []struct { - name string - parts []MessagePart - expectedStatus api.CheckStatus - }{ - { - name: "With MIME parts", - parts: []MessagePart{ - {ContentType: "text/plain", Content: "test"}, - {ContentType: "text/html", Content: "

    test

    "}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No MIME parts", - parts: []MessagePart{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - Parts: tt.parts, - } - - check := scorer.generateMIMEStructureCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateHeaderChecks(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minChecks int - }{ - { - name: "Nil email", - email: nil, - minChecks: 0, - }, - { - name: "Complete email", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minChecks: 4, // Required, Recommended, Message-ID, MIME - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := scorer.GenerateHeaderChecks(tt.email) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Headers category - for _, check := range checks { - if check.Category != api.Headers { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) - } - } - }) - } -} - -func TestGetScoreSummary(t *testing.T) { - result := &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - Recommendations: []string{ - "Improve content quality", - "Add more headers", - }, - } - - scorer := NewDeliverabilityScorer() - summary := scorer.GetScoreSummary(result) - - // Check that summary contains key information - if !strings.Contains(summary, "8.5") { - t.Error("Summary should contain overall score") - } - if !strings.Contains(summary, "Good") { - t.Error("Summary should contain rating") - } - if !strings.Contains(summary, "Authentication") { - t.Error("Summary should contain category names") - } - if !strings.Contains(summary, "Recommendations") { - t.Error("Summary should contain recommendations section") - } -} - -// Helper function to create mail.Header with specific fields -func createHeaderWithFields(fields map[string]string) mail.Header { - header := make(mail.Header) - for key, value := range fields { - if value != "" { - // Use canonical MIME header key format - canonicalKey := textproto.CanonicalMIMEHeaderKey(key) - header[canonicalKey] = []string{value} - } - } - return header -} diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 2a3ff60..a3f175f 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -23,6 +23,7 @@ package analyzer import ( "fmt" + "math" "regexp" "strconv" "strings" @@ -174,41 +175,28 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-20 points) -// Scoring: -// - Score <= 0: 20 points (excellent) -// - Score < required: 15 points (good) -// - Score slightly above required (< 2x): 10 points (borderline) -// - Score moderately high (< 3x required): 5 points (poor) -// - Score very high: 0 points (spam) -func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { +// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability +func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) int { if result == nil { - return 0.0 + return 0 } score := result.Score required := result.RequiredScore if required == 0 { - required = 5.0 // Default SpamAssassin threshold + required = 5 // Default SpamAssassin threshold } // Calculate deliverability score if score <= 0 { - return 20.0 - } else if score < required { - // Linear scaling from 15 to 20 based on how negative/low the score is - ratio := score / required - return 15.0 + (5.0 * (1.0 - float32(ratio))) - } else if score < required*2 { - // Slightly above threshold - return 10.0 - } else if score < required*3 { - // Moderately high - return 5.0 + return 100 + } + if score <= required*4 { + return 0 } - // Very high spam score - return 0.0 + // Linear scaling based on how negative/low the score is + return 100 - int(math.Round(25*score/required)) } // GenerateSpamAssassinChecks generates check results for SpamAssassin analysis @@ -259,9 +247,8 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) required = 5.0 } - delivScore := a.GetSpamAssassinScore(result) - check.Score = delivScore - check.Grade = ScoreToCheckGrade((delivScore / 20.0) * 100) + check.Score = a.GetSpamAssassinScore(result) + check.Grade = ScoreToCheckGrade(check.Score) // Determine status and message based on score if score <= 0 { @@ -320,7 +307,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec check.Severity = api.PtrTo(api.CheckSeverityMedium) } check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) + check.Grade = ScoreToCheckGrade(0) check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) check.Advice = &advice @@ -339,11 +326,3 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec return check } - -// min returns the minimum of two integers -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index deed1c7..54b9c0c 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -154,14 +154,14 @@ func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string result *SpamAssassinResult - expectedScore float32 - minScore float32 - maxScore float32 + expectedScore int + minScore int + maxScore int }{ { name: "Nil result", result: nil, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Excellent score (negative)", @@ -169,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 20.0, + expectedScore: 100, }, { name: "Good score (below threshold)", @@ -177,8 +177,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 2.0, RequiredScore: 5.0, }, - minScore: 15.0, - maxScore: 20.0, + minScore: 80, + maxScore: 100, }, { name: "Borderline (just above threshold)", @@ -186,7 +186,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 10.0, + minScore: 60, + maxScore: 80, }, { name: "High spam score", @@ -194,7 +195,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 5.0, + minScore: 20, + maxScore: 50, }, { name: "Very high spam score", @@ -202,7 +204,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -618,8 +620,8 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Test GetSpamAssassinScore score := analyzer.GetSpamAssassinScore(result) - if score != 20.0 { - t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) + if score != 100 { + t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) } // Test GenerateSpamAssassinChecks @@ -639,14 +641,14 @@ func TestAnalyzeRealEmailExample(t *testing.T) { if !strings.Contains(mainCheck.Message, "spam score") { t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message) } - if mainCheck.Score != 20.0 { - t.Errorf("Main check score = %v, want 20.0", mainCheck.Score) + if mainCheck.Score != 100 { + t.Errorf("Main check score = %v, want 100", 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)", + t.Logf(" Check %d: %s - %s (score: %d, status: %s)", i+1, check.Name, check.Message, check.Score, check.Status) } } diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte index abd200f..de84a70 100644 --- a/web/src/lib/components/CheckCard.svelte +++ b/web/src/lib/components/CheckCard.svelte @@ -32,7 +32,7 @@
    {check.name}
    - {check.score.toFixed(1)} pts + {check.score}%

    {check.message}

    @@ -48,7 +48,7 @@ {#if check.details}
    Technical Details -
    {check.details}
    +
    {check.details}
    {/if}
    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index c520c79..0b74a38 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -2,25 +2,26 @@ import type { ScoreSummary } from "$lib/api/types.gen"; interface Props { + grade: string; score: number; summary?: ScoreSummary; } - let { score, summary }: Props = $props(); + let { grade, score, summary }: Props = $props(); function getScoreClass(score: number): string { - if (score >= 9) return "score-excellent"; - if (score >= 7) return "score-good"; - if (score >= 5) return "score-warning"; - if (score >= 3) return "score-poor"; + if (score >= 90) return "score-excellent"; + if (score >= 70) return "score-good"; + if (score >= 50) return "score-warning"; + if (score >= 30) return "score-poor"; return "score-bad"; } function getScoreLabel(score: number): string { - if (score >= 9) return "Excellent"; - if (score >= 7) return "Good"; - if (score >= 5) return "Fair"; - if (score >= 3) return "Poor"; + if (score >= 90) return "Excellent"; + if (score >= 70) return "Good"; + if (score >= 50) return "Fair"; + if (score >= 30) return "Poor"; return "Critical"; } @@ -28,7 +29,7 @@

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

    {getScoreLabel(score)}

    Overall Deliverability Score

    @@ -39,12 +40,12 @@
    = 3} - class:text-warning={summary.authentication_score < 3 && - summary.authentication_score >= 1.5} - class:text-danger={summary.authentication_score < 1.5} + class:text-success={summary.authentication_score >= 100} + class:text-warning={summary.authentication_score < 100 && + summary.authentication_score >= 50} + class:text-danger={summary.authentication_score < 50} > - {summary.authentication_score.toFixed(1)}/3 + {summary.authentication_score}% Authentication
    @@ -53,11 +54,11 @@
    = 2} - class:text-warning={summary.spam_score < 2 && summary.spam_score >= 1} - class:text-danger={summary.spam_score < 1} + class:text-success={summary.spam_score >= 100} + class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50} + class:text-danger={summary.spam_score < 50} > - {summary.spam_score.toFixed(1)}/2 + {summary.spam_score}% Spam Score
    @@ -66,12 +67,12 @@
    = 2} - class:text-warning={summary.blacklist_score < 2 && - summary.blacklist_score >= 1} - class:text-danger={summary.blacklist_score < 1} + class:text-success={summary.blacklist_score >= 100} + class:text-warning={summary.blacklist_score < 100 && + summary.blacklist_score >= 50} + class:text-danger={summary.blacklist_score < 50} > - {summary.blacklist_score.toFixed(1)}/2 + {summary.blacklist_score}% Blacklists
    @@ -80,12 +81,12 @@
    = 2} - class:text-warning={summary.content_score < 2 && - summary.content_score >= 1} - class:text-danger={summary.content_score < 1} + class:text-success={summary.content_score >= 100} + class:text-warning={summary.content_score < 100 && + summary.content_score >= 50} + class:text-danger={summary.content_score < 50} > - {summary.content_score.toFixed(1)}/2 + {summary.content_score}% Content
    @@ -94,12 +95,12 @@
    = 1} - class:text-warning={summary.header_score < 1 && - summary.header_score >= 0.5} - class:text-danger={summary.header_score < 0.5} + class:text-success={summary.header_score >= 100} + class:text-warning={summary.header_score < 100 && + summary.header_score >= 50} + class:text-danger={summary.header_score < 50} > - {summary.header_score.toFixed(1)}/1 + {summary.header_score}% Headers
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index db3e447..fd36ce7 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -17,9 +17,9 @@ // Group checks by category let groupedChecks = $derived(() => { - if (!report) return {}; + if (!report) return { }; - const groups: Record = {}; + const groups: Record = { }; for (const check of report.checks) { if (!groups[check.category]) { groups[check.category] = []; @@ -106,31 +106,10 @@ } function getCategoryScore(checks: typeof report.checks): number { - return checks.reduce((sum, check) => sum + check.score, 0); + return Math.round(checks.reduce((sum, check) => sum + check.score, 0) / checks.filter((c) => c.status != "info").length); } - function getCategoryMaxScore(category: string): number { - switch (category) { - case "authentication": - return 3; - case "spam": - return 2; - case "blacklist": - return 2; - case "content": - return 2; - case "headers": - return 1; - case "dns": - return 0; // DNS checks contribute to other categories - default: - return 0; - } - } - - function getScoreColorClass(score: number, maxScore: number): string { - if (maxScore === 0) return "text-muted"; - const percentage = (score / maxScore) * 100; + function getScoreColorClass(percentage: number): string { if (percentage >= 80) return "text-success"; if (percentage >= 50) return "text-warning"; return "text-danger"; @@ -189,7 +168,7 @@
    - +
    @@ -199,15 +178,14 @@

    Detailed Checks

    {#each Object.entries(groupedChecks()) as [category, checks]} {@const categoryScore = getCategoryScore(checks)} - {@const maxScore = getCategoryMaxScore(category)}

    {category} - - {categoryScore.toFixed(1)}{#if maxScore > 0} / {maxScore}{/if} pts + + {categoryScore}%

    {#each checks as check} From 1be917136c888f30c443606226b20c1b40da1596 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 12:52:38 +0700 Subject: [PATCH 050/256] Handle RBL error report --- pkg/analyzer/rbl.go | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 2084ea5..2c7833b 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -211,10 +211,19 @@ func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { return check } - // If we got a response, the IP is listed + // If we got a response, check the return code if len(addrs) > 0 { - check.Listed = true check.Response = addrs[0] // Return code (e.g., 127.0.0.2) + + // Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 + // These indicate RBL operational issues, not actual listings + if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" { + check.Listed = false + check.Error = fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]) + } else { + // Normal listing response + check.Listed = true + } } return check @@ -275,11 +284,15 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { summaryCheck := r.generateSummaryCheck(results) checks = append(checks, summaryCheck) - // Create individual checks for each listing + // Create individual checks for each listing and RBL errors for _, check := range results.Checks { if check.Listed { detailCheck := r.generateListingCheck(&check) checks = append(checks, detailCheck) + } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { + // Generate info check for RBL errors + detailCheck := r.generateRBLErrorCheck(&check) + checks = append(checks, detailCheck) } } @@ -367,6 +380,30 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { return check } +// generateRBLErrorCheck creates an info-level check for RBL operational errors +func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { + check := api.Check{ + Category: api.Blacklist, + Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), + Status: api.CheckStatusInfo, + Score: 0, // No penalty for RBL operational issues + Grade: ScoreToCheckGrade(-1), + Severity: api.PtrTo(api.CheckSeverityInfo), + } + + check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, rblCheck.IP) + + advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response) + check.Advice = &advice + + if rblCheck.Response != "" { + details := fmt.Sprintf("Error code: %s (RBL operational issue, not a listing)", rblCheck.Response) + check.Details = &details + } + + return check +} + // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { seenIPs := make(map[string]bool) From 954a9d705e0d708ffdc3c5801d45b7c184dbc309 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 14:42:18 +0700 Subject: [PATCH 051/256] Change RBL test to return map of ips --- api/openapi.yaml | 20 ++++++----- pkg/analyzer/rbl.go | 62 ++++++++++++++++++++--------------- pkg/analyzer/rbl_test.go | 71 +++++++++++++++++++++++++--------------- pkg/analyzer/report.go | 32 +++++++++++------- 4 files changed, 113 insertions(+), 72 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 6762439..a44a588 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -277,9 +277,18 @@ components: items: $ref: '#/components/schemas/DNSRecord' blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + listed: false + - rbl: "bl.spamcop.net" + listed: false raw_headers: type: string description: Raw email headers @@ -498,14 +507,9 @@ components: BlacklistCheck: type: object required: - - ip - rbl - listed properties: - ip: - type: string - description: IP address checked - example: "192.0.2.1" rbl: type: string description: RBL/DNSBL name diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 2c7833b..f13e681 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -68,14 +68,14 @@ func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { // RBLResults represents the results of RBL checks type RBLResults struct { - Checks []RBLCheck + Checks map[string][]RBLCheck // Map of IP -> list of RBL checks for that IP IPsChecked []string ListedCount int } // RBLCheck represents a single RBL check result +// Note: IP is not included here as it's used as the map key in the API type RBLCheck struct { - IP string RBL string Listed bool Response string @@ -84,7 +84,9 @@ type RBLCheck struct { // CheckEmail checks all IPs found in the email headers against RBLs func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{} + results := &RBLResults{ + Checks: make(map[string][]RBLCheck), + } // Extract IPs from Received headers ips := r.extractIPs(email) @@ -98,7 +100,7 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { for _, ip := range ips { for _, rbl := range r.RBLs { check := r.checkIP(ip, rbl) - results.Checks = append(results.Checks, check) + results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ } @@ -179,7 +181,6 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { // checkIP checks a single IP against a single RBL func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { check := RBLCheck{ - IP: ip, RBL: rbl, } @@ -285,14 +286,16 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { checks = append(checks, summaryCheck) // Create individual checks for each listing and RBL errors - for _, check := range results.Checks { - if check.Listed { - detailCheck := r.generateListingCheck(&check) - checks = append(checks, detailCheck) - } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { - // Generate info check for RBL errors - detailCheck := r.generateRBLErrorCheck(&check) - checks = append(checks, detailCheck) + for ip, rblChecks := range results.Checks { + for _, check := range rblChecks { + if check.Listed { + detailCheck := r.generateListingCheck(ip, &check) + checks = append(checks, detailCheck) + } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { + // Generate info check for RBL errors + detailCheck := r.generateRBLErrorCheck(ip, &check) + checks = append(checks, detailCheck) + } } } @@ -310,7 +313,11 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { check.Score = score check.Grade = ScoreToCheckGrade(score) - totalChecks := len(results.Checks) + // Calculate total checks across all IPs + totalChecks := 0 + for _, rblChecks := range results.Checks { + totalChecks += len(rblChecks) + } listedCount := results.ListedCount if listedCount == 0 { @@ -345,7 +352,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { } // generateListingCheck creates a check for a specific RBL listing -func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { +func (r *RBLChecker) generateListingCheck(ip string, rblCheck *RBLCheck) api.Check { check := api.Check{ Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), @@ -354,7 +361,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Grade: ScoreToCheckGrade(0), } - check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) + check.Message = fmt.Sprintf("IP %s is listed on %s", ip, rblCheck.RBL) // Determine severity based on which RBL if strings.Contains(rblCheck.RBL, "spamhaus") { @@ -381,7 +388,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { } // generateRBLErrorCheck creates an info-level check for RBL operational errors -func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { +func (r *RBLChecker) generateRBLErrorCheck(ip string, rblCheck *RBLCheck) api.Check { check := api.Check{ Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), @@ -391,7 +398,7 @@ func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { Severity: api.PtrTo(api.CheckSeverityInfo), } - check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, rblCheck.IP) + check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, ip) advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response) check.Advice = &advice @@ -406,13 +413,14 @@ func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { - seenIPs := make(map[string]bool) var listedIPs []string - for _, check := range results.Checks { - if check.Listed && !seenIPs[check.IP] { - listedIPs = append(listedIPs, check.IP) - seenIPs[check.IP] = true + for ip, rblChecks := range results.Checks { + for _, check := range rblChecks { + if check.Listed { + listedIPs = append(listedIPs, ip) + break // Only add the IP once + } } } @@ -423,9 +431,11 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { var rbls []string - for _, check := range results.Checks { - if check.IP == ip && check.Listed { - rbls = append(rbls, check.RBL) + if rblChecks, exists := results.Checks[ip]; exists { + for _, check := range rblChecks { + if check.Listed { + rbls = append(rbls, check.RBL) + } } } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index c2bac11..2bd5c35 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -347,7 +347,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, - Checks: make([]RBLCheck, 6), // 6 default RBLs + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), // 6 default RBLs + }, }, expectedStatus: api.CheckStatusPass, expectedScore: 200, @@ -357,7 +359,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusWarn, expectedScore: 100, @@ -367,7 +371,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusWarn, expectedScore: 50, @@ -377,7 +383,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusFail, expectedScore: 0, @@ -413,7 +421,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "Spamhaus listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true, Response: "127.0.0.2", @@ -424,7 +431,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "SpamCop listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true, Response: "127.0.0.2", @@ -435,7 +441,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "Other RBL listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: true, Response: "127.0.0.2", @@ -449,7 +454,7 @@ func TestGenerateListingCheck(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - check := checker.generateListingCheck(tt.rblCheck) + check := checker.generateListingCheck("198.51.100.1", tt.rblCheck) if check.Status != tt.expectedStatus { t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) @@ -490,9 +495,11 @@ func TestGenerateRBLChecks(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: false}, + {RBL: "bl.spamcop.net", Listed: false}, + }, }, }, minChecks: 1, // Summary check only @@ -502,10 +509,12 @@ func TestGenerateRBLChecks(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + {RBL: "dnsbl.sorbs.net", Listed: false}, + }, }, }, minChecks: 3, // Summary + 2 listing checks @@ -534,12 +543,18 @@ func TestGenerateRBLChecks(t *testing.T) { func TestGetUniqueListedIPs(t *testing.T) { results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false}, - {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + }, + "198.51.100.2": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: false}, + }, + "198.51.100.3": { + {RBL: "zen.spamhaus.org", Listed: false}, + }, }, } @@ -556,11 +571,15 @@ func TestGetUniqueListedIPs(t *testing.T) { func TestGetRBLsForIP(t *testing.T) { results := &RBLResults{ - Checks: []RBLCheck{ - {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, - {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, - {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, - {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + {RBL: "dnsbl.sorbs.net", Listed: false}, + }, + "198.51.100.2": { + {RBL: "zen.spamhaus.org", Listed: true}, + }, }, } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6d5522b..78a0b5e 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -190,21 +190,29 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } } - // Add blacklist checks + // Add blacklist checks as a map of IP -> array of BlacklistCheck if results.RBL != nil && len(results.RBL.Checks) > 0 { - blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) - for _, check := range results.RBL.Checks { - blCheck := api.BlacklistCheck{ - Ip: check.IP, - Rbl: check.RBL, - Listed: check.Listed, + blacklistMap := make(map[string][]api.BlacklistCheck) + + // Convert internal RBL checks to API format + for ip, rblChecks := range results.RBL.Checks { + apiChecks := make([]api.BlacklistCheck, 0, len(rblChecks)) + for _, check := range rblChecks { + blCheck := api.BlacklistCheck{ + Rbl: check.RBL, + Listed: check.Listed, + } + if check.Response != "" { + blCheck.Response = &check.Response + } + apiChecks = append(apiChecks, blCheck) } - if check.Response != "" { - blCheck.Response = &check.Response - } - blacklistChecks = append(blacklistChecks, blCheck) + blacklistMap[ip] = apiChecks + } + + if len(blacklistMap) > 0 { + report.Blacklists = &blacklistMap } - report.Blacklists = &blacklistChecks } // Add raw headers From d87b0cbcb0028ed19bc140639ac3d4c8652458c9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 15:27:43 +0700 Subject: [PATCH 052/256] Remove checks --- api/openapi.yaml | 305 ++++++++++-- internal/app/cli_analyzer.go | 38 +- pkg/analyzer/authentication.go | 80 +++- pkg/analyzer/authentication_checks.go | 317 ------------- pkg/analyzer/authentication_test.go | 396 +--------------- pkg/analyzer/content.go | 395 +++++---------- pkg/analyzer/content_test.go | 448 ------------------ pkg/analyzer/dns.go | 229 --------- pkg/analyzer/dns_test.go | 421 ---------------- pkg/analyzer/headers.go | 375 +++++++-------- pkg/analyzer/headers_test.go | 365 ++++++++------ pkg/analyzer/rbl.go | 186 +------- pkg/analyzer/rbl_test.go | 231 +-------- pkg/analyzer/report.go | 145 ++---- pkg/analyzer/report_test.go | 279 ----------- pkg/analyzer/scoring.go | 31 +- pkg/analyzer/scoring_test.go | 252 +--------- pkg/analyzer/spamassassin.go | 131 ----- pkg/analyzer/spamassassin_test.go | 202 -------- .../lib/components/AuthenticationCard.svelte | 210 ++++++++ web/src/lib/components/BlacklistCard.svelte | 60 +++ web/src/lib/components/CheckCard.svelte | 71 --- .../lib/components/ContentAnalysisCard.svelte | 165 +++++++ web/src/lib/components/DnsRecordsCard.svelte | 46 ++ .../lib/components/HeaderAnalysisCard.svelte | 169 +++++++ web/src/lib/components/ScoreCard.svelte | 54 +-- web/src/lib/components/index.ts | 6 +- web/src/routes/test/[test]/+page.svelte | 129 ++--- 28 files changed, 1726 insertions(+), 4010 deletions(-) delete mode 100644 pkg/analyzer/authentication_checks.go create mode 100644 web/src/lib/components/AuthenticationCard.svelte create mode 100644 web/src/lib/components/BlacklistCard.svelte delete mode 100644 web/src/lib/components/CheckCard.svelte create mode 100644 web/src/lib/components/ContentAnalysisCard.svelte create mode 100644 web/src/lib/components/DnsRecordsCard.svelte create mode 100644 web/src/lib/components/HeaderAnalysisCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index a44a588..c78c71b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -240,7 +240,6 @@ components: - test_id - score - grade - - checks - created_at properties: id: @@ -264,10 +263,6 @@ components: example: "A" summary: $ref: '#/components/schemas/ScoreSummary' - checks: - type: array - items: - $ref: '#/components/schemas/Check' authentication: $ref: '#/components/schemas/AuthenticationResults' spamassassin: @@ -289,6 +284,10 @@ components: listed: false - rbl: "bl.spamcop.net" listed: false + content_analysis: + $ref: '#/components/schemas/ContentAnalysis' + header_analysis: + $ref: '#/components/schemas/HeaderAnalysis' raw_headers: type: string description: Raw email headers @@ -336,55 +335,272 @@ components: description: Header quality score (in percentage) example: 9 - Check: + ContentAnalysis: + type: object + properties: + has_html: + type: boolean + description: Whether email contains HTML part + example: true + has_plaintext: + type: boolean + description: Whether email contains plaintext part + example: true + html_issues: + type: array + items: + $ref: '#/components/schemas/ContentIssue' + description: Issues found in HTML content + links: + type: array + items: + $ref: '#/components/schemas/LinkCheck' + description: Analysis of links found in the email + images: + type: array + items: + $ref: '#/components/schemas/ImageCheck' + description: Analysis of images in the email + text_to_image_ratio: + type: number + format: float + description: Ratio of text to images (higher is better) + example: 0.75 + has_unsubscribe_link: + type: boolean + description: Whether email contains an unsubscribe link + example: true + unsubscribe_methods: + type: array + items: + type: string + enum: [link, mailto, list-unsubscribe-header, one-click] + description: Available unsubscribe methods + example: ["link", "list-unsubscribe-header"] + + ContentIssue: type: object required: - - category - - name - - status - - score - - grade + - type + - severity - message properties: - category: + type: type: string - enum: [authentication, dns, content, blacklist, headers, spam] - description: Check category - example: "authentication" - name: - type: string - description: Check name - example: "DKIM Signature" - status: - type: string - enum: [pass, fail, warn, info, error] - description: Check result status - example: "pass" - score: - type: integer - description: Points contributed to total score - example: 10 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - message: - type: string - description: Human-readable result message - example: "DKIM signature is valid" - details: - type: string - description: Additional details (may be JSON) + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link] + description: Type of content issue + example: "missing_alt" severity: type: string enum: [critical, high, medium, low, info] description: Issue severity - example: "info" + example: "medium" + message: + type: string + description: Human-readable description + example: "3 images are missing alt attributes" + location: + type: string + description: Where the issue was found + example: "HTML body line 42" advice: type: string - description: Remediation advice - example: "Your DKIM configuration is correct" + description: How to fix this issue + example: "Add descriptive alt text to all images for better accessibility and deliverability" + + LinkCheck: + type: object + required: + - url + - status + properties: + url: + type: string + format: uri + description: The URL found in the email + example: "https://example.com/page" + status: + type: string + enum: [valid, broken, suspicious, redirected, timeout] + description: Link validation status + example: "valid" + http_code: + type: integer + description: HTTP status code received + example: 200 + redirect_chain: + type: array + items: + type: string + description: URLs in the redirect chain, if any + example: ["https://example.com", "https://www.example.com"] + is_shortened: + type: boolean + description: Whether this is a URL shortener + example: false + + ImageCheck: + type: object + required: + - has_alt + properties: + src: + type: string + description: Image source URL or path + example: "https://example.com/logo.png" + has_alt: + type: boolean + description: Whether image has alt attribute + example: true + alt_text: + type: string + description: Alt text content + example: "Company Logo" + is_tracking_pixel: + type: boolean + description: Whether this appears to be a tracking pixel (1x1 image) + example: false + + HeaderAnalysis: + type: object + properties: + has_mime_structure: + type: boolean + description: Whether body has a MIME structure + example: true + headers: + type: object + additionalProperties: + $ref: '#/components/schemas/HeaderCheck' + description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") + example: + from: + present: true + value: "sender@example.com" + valid: true + importance: "required" + date: + present: true + value: "Mon, 1 Jan 2024 12:00:00 +0000" + valid: true + importance: "required" + received_chain: + type: array + items: + $ref: '#/components/schemas/ReceivedHop' + description: Chain of Received headers showing email path + domain_alignment: + $ref: '#/components/schemas/DomainAlignment' + issues: + type: array + items: + $ref: '#/components/schemas/HeaderIssue' + description: Issues found in headers + + HeaderCheck: + type: object + required: + - present + properties: + present: + type: boolean + description: Whether the header is present + example: true + value: + type: string + description: Header value + example: "sender@example.com" + valid: + type: boolean + description: Whether the value is valid/well-formed + example: true + importance: + type: string + enum: [required, recommended, optional] + description: How important this header is for deliverability + example: "required" + issues: + type: array + items: + type: string + description: Any issues with this header + example: ["Invalid date format"] + + ReceivedHop: + type: object + properties: + from: + type: string + description: Sending server hostname + example: "mail.example.com" + by: + type: string + description: Receiving server hostname + example: "mx.receiver.com" + with: + type: string + description: Protocol used + example: "ESMTPS" + id: + type: string + description: Message ID at this hop + timestamp: + type: string + format: date-time + description: When this hop occurred + + DomainAlignment: + type: object + properties: + from_domain: + type: string + description: Domain from From header + example: "example.com" + return_path_domain: + type: string + description: Domain from Return-Path header + example: "example.com" + dkim_domains: + type: array + items: + type: string + description: Domains from DKIM signatures + example: ["example.com"] + aligned: + type: boolean + description: Whether all domains align + example: true + issues: + type: array + items: + type: string + description: Alignment issues + example: ["Return-Path domain does not match From domain"] + + HeaderIssue: + type: object + required: + - header + - severity + - message + properties: + header: + type: string + description: Header name + example: "Date" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "Date header is in the future" + advice: + type: string + description: How to fix this issue + example: "Ensure your mail server clock is synchronized with NTP" AuthenticationResults: type: object @@ -522,6 +738,9 @@ components: type: string description: RBL response code or message example: "127.0.0.2" + error: + type: string + description: RBL error if any Status: type: object diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 03a1720..4334711 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,7 +31,6 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/pkg/analyzer" ) @@ -97,42 +96,7 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze 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) - } - } - } + // TODO fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) return nil diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index eef44b1..310c8f6 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -191,7 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = &part + result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) return result } @@ -215,7 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - result.Details = &part + result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) return result } @@ -246,7 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = &part + result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) return result } @@ -263,7 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { result.Result = api.ARCResultResult(resultStr) } - result.Details = &part + result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) return result } @@ -467,3 +467,75 @@ func textprotoCanonical(s string) string { } return strings.Join(words, "-") } + +// CalculateAuthenticationScore calculates the authentication score from auth results +// Returns a score from 0-100 where higher is better +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) int { + if results == nil { + return 0 + } + + score := 0 + + // SPF (30 points) + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 30 + case api.AuthResultResultNeutral, api.AuthResultResultNone: + score += 15 + case api.AuthResultResultSoftfail: + score += 5 + default: // fail, temperror, permerror + score += 0 + } + } + + // DKIM (30 points) - at least one passing signature + if results.Dkim != nil && len(*results.Dkim) > 0 { + hasPass := false + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + hasPass = true + break + } + } + if hasPass { + score += 30 + } else { + // Has DKIM signatures but none passed + score += 10 + } + } + + // DMARC (30 points) + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + score += 30 + case api.AuthResultResultNone: + score += 10 + default: // fail + score += 0 + } + } + + // BIMI (10 points) + if results.Bimi != nil { + switch results.Bimi.Result { + case api.AuthResultResultPass: + score += 10 + case api.AuthResultResultNone: + score += 5 + default: // fail + score += 0 + } + } + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + + return score +} diff --git a/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go deleted file mode 100644 index f7cc15e..0000000 --- a/pkg/analyzer/authentication_checks.go +++ /dev/null @@ -1,317 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "strings" - - "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, - 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, - 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, - 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 = 100 - check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 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 = 50 - 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 = 50 - 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 - 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.Details != nil { - check.Details = spf.Details - } else 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 = 10 - 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 - 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 - 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") - } - - if dkim.Details != nil { - check.Details = dkim.Details - } else { - 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 = 10 - 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 - 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 - 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.Details != nil { - check.Details = dmarc.Details - } else 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 // 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 - 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 - 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.Details != nil { - check.Details = bimi.Details - } else 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 // 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 - 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 - 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") - } - - if arc.Details != nil { - check.Details = arc.Details - } else { - // 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 index 0b03998..e7f1e06 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -22,7 +22,6 @@ package analyzer import ( - "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -246,250 +245,6 @@ func TestParseBIMIResult(t *testing.T) { } } -func TestGenerateAuthSPFCheck(t *testing.T) { - tests := []struct { - name string - spf *api.AuthResult - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "SPF pass", - spf: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "SPF fail", - spf: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - { - name: "SPF softfail", - spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 5, - }, - { - name: "SPF neutral", - spf: &api.AuthResult{ - Result: api.AuthResultResultNeutral, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 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 int - }{ - { - name: "DKIM pass", - dkim: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - Selector: api.PtrTo("default"), - }, - index: 0, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - 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, - }, - { - 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, - }, - } - - 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 int - }{ - { - name: "DMARC pass", - dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "DMARC fail", - dmarc: &api.AuthResult{ - Result: api.AuthResultResultFail, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 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 int - }{ - { - name: "BIMI pass", - bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 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, - }, - { - name: "BIMI none", - bimi: &api.AuthResult{ - Result: api.AuthResultResultNone, - Domain: api.PtrTo("example.com"), - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 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 @@ -563,11 +318,11 @@ func TestGetAuthenticationScore(t *testing.T) { }, } - scorer := NewDeliverabilityScorer() + scorer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := scorer.GetAuthenticationScore(tt.results) + score := scorer.CalculateAuthenticationScore(tt.results) if score != tt.expectedScore { t.Errorf("Score = %v, want %v", score, tt.expectedScore) @@ -576,92 +331,6 @@ func TestGetAuthenticationScore(t *testing.T) { } } -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 @@ -783,64 +452,3 @@ func TestValidateARCChain(t *testing.T) { }) } } - -func TestGenerateARCCheck(t *testing.T) { - tests := []struct { - name string - arc *api.ARCResult - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "ARC pass", - arc: &api.ARCResult{ - Result: api.ARCResultResultPass, - ChainLength: api.PtrTo(2), - ChainValid: api.PtrTo(true), - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 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, - }, - { - name: "ARC none", - arc: &api.ARCResult{ - Result: api.ARCResultResultNone, - ChainLength: api.PtrTo(0), - ChainValid: api.PtrTo(true), - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 0, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateARCCheck(tt.arc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Authentication { - t.Errorf("Category = %v, want %v", check.Category, api.Authentication) - } - if !strings.Contains(check.Name, "ARC") { - t.Errorf("Name should contain 'ARC', got %q", check.Name) - } - }) - } -} diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 872c75c..7964693 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -459,294 +459,151 @@ func (c *ContentAnalyzer) normalizeText(text string) string { return text } -// GenerateContentChecks generates check results for content analysis -func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check { - var checks []api.Check - +// GenerateContentAnalysis creates structured content analysis from results +func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis { if results == nil { - return checks + return nil } - // HTML validity check - checks = append(checks, c.generateHTMLValidityCheck(results)) - - // Link checks - checks = append(checks, c.generateLinkChecks(results)...) - - // Image checks - checks = append(checks, c.generateImageChecks(results)...) - - // Unsubscribe link check - checks = append(checks, c.generateUnsubscribeCheck(results)) - - // Text/HTML consistency check - if results.TextContent != "" && results.HTMLContent != "" { - checks = append(checks, c.generateTextConsistencyCheck(results)) + analysis := &api.ContentAnalysis{ + HasHtml: api.PtrTo(results.HTMLContent != ""), + HasPlaintext: api.PtrTo(results.TextContent != ""), + HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), } - // Image-to-text ratio check + // Calculate text-to-image ratio (inverse of image-to-text) if len(results.Images) > 0 && results.HTMLContent != "" { - checks = append(checks, c.generateImageRatioCheck(results)) - } - - // Suspicious URLs check - if len(results.SuspiciousURLs) > 0 { - checks = append(checks, c.generateSuspiciousURLCheck(results)) - } - - return checks -} - -// generateHTMLValidityCheck creates a check for HTML validity -func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "HTML Structure", - } - - if !results.HTMLValid { - check.Status = api.CheckStatusFail - check.Score = 0 - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "HTML structure is invalid" - if len(results.HTMLErrors) > 0 { - details := strings.Join(results.HTMLErrors, "; ") - check.Details = &details - } - check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering") - } else { - check.Status = api.CheckStatusPass - check.Score = 2 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "HTML structure is valid" - check.Advice = api.PtrTo("Your HTML is well-formed") - } - - return check -} - -// generateLinkChecks creates checks for links -func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check { - var checks []api.Check - - if len(results.Links) == 0 { - return checks - } - - // Count broken links - brokenLinks := 0 - warningLinks := 0 - for _, link := range results.Links { - if link.Status >= 400 { - brokenLinks++ - } else if link.Warning != "" { - warningLinks++ + textLen := float32(len(c.extractTextFromHTML(results.HTMLContent))) + if textLen > 0 { + ratio := textLen / float32(len(results.Images)) + analysis.TextToImageRatio = &ratio } } - check := api.Check{ - Category: api.Content, - Name: "Links", - } + // Build HTML issues + htmlIssues := []api.ContentIssue{} - if brokenLinks > 0 { - check.Status = api.CheckStatusFail - check.Score = 0 - 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) - check.Details = &details - } else if warningLinks > 0 { - check.Status = api.CheckStatusWarn - check.Score = 3 - 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) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 4 - 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") - } - - checks = append(checks, check) - return checks -} - -// generateImageChecks creates checks for images -func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check { - var checks []api.Check - - if len(results.Images) == 0 { - return checks - } - - // Count images without alt text - noAltCount := 0 - for _, img := range results.Images { - if !img.HasAlt { - noAltCount++ + // Add HTML parsing errors + if !results.HTMLValid && len(results.HTMLErrors) > 0 { + for _, errMsg := range results.HTMLErrors { + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.BrokenHtml, + Severity: api.ContentIssueSeverityHigh, + Message: errMsg, + Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"), + }) } } - check := api.Check{ - Category: api.Content, - Name: "Image Alt Attributes", + // Add missing alt text issues + if len(results.Images) > 0 { + missingAltCount := 0 + for _, img := range results.Images { + if !img.HasAlt { + missingAltCount++ + } + } + if missingAltCount > 0 { + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.MissingAlt, + Severity: api.ContentIssueSeverityMedium, + Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount), + Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), + }) + } } - if noAltCount == len(results.Images) { - check.Status = api.CheckStatusFail - check.Score = 0 - 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)) - check.Details = &details - } else if noAltCount > 0 { - check.Status = api.CheckStatusWarn - check.Score = 2 - 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)) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 3 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "All images have alt attributes" - check.Advice = api.PtrTo("Your images are properly tagged for accessibility") + // Add excessive images issue + if results.ImageTextRatio > 10.0 { + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.ExcessiveImages, + Severity: api.ContentIssueSeverityMedium, + Message: "Email is excessively image-heavy", + Advice: api.PtrTo("Reduce the number of images relative to text content"), + }) } - checks = append(checks, check) - return checks + // Add suspicious URL issues + for _, suspURL := range results.SuspiciousURLs { + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.SuspiciousLink, + Severity: api.ContentIssueSeverityHigh, + Message: "Suspicious URL detected", + Location: &suspURL, + Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), + }) + } + + if len(htmlIssues) > 0 { + analysis.HtmlIssues = &htmlIssues + } + + // Convert links + if len(results.Links) > 0 { + links := make([]api.LinkCheck, 0, len(results.Links)) + for _, link := range results.Links { + status := api.Valid + if link.Status >= 400 { + status = api.Broken + } else if !link.IsSafe { + status = api.Suspicious + } else if link.Warning != "" { + status = api.Timeout + } + + apiLink := api.LinkCheck{ + Url: link.URL, + Status: status, + } + + if link.Status > 0 { + apiLink.HttpCode = api.PtrTo(link.Status) + } + + // Check if it's a URL shortener + parsedURL, err := url.Parse(link.URL) + if err == nil { + isShortened := c.isSuspiciousURL(link.URL, parsedURL) + apiLink.IsShortened = api.PtrTo(isShortened) + } + + links = append(links, apiLink) + } + analysis.Links = &links + } + + // Convert images + if len(results.Images) > 0 { + images := make([]api.ImageCheck, 0, len(results.Images)) + for _, img := range results.Images { + apiImg := api.ImageCheck{ + HasAlt: img.HasAlt, + } + if img.Src != "" { + apiImg.Src = &img.Src + } + if img.AltText != "" { + apiImg.AltText = &img.AltText + } + // Simple heuristic: tracking pixels are typically 1x1 + apiImg.IsTrackingPixel = api.PtrTo(false) + + images = append(images, apiImg) + } + analysis.Images = &images + } + + // Unsubscribe methods + if results.HasUnsubscribe { + methods := []api.ContentAnalysisUnsubscribeMethods{api.Link} + analysis.UnsubscribeMethods = &methods + } + + return analysis } -// generateUnsubscribeCheck creates a check for unsubscribe links -func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Unsubscribe Link", - } - - if !results.HasUnsubscribe { - check.Status = api.CheckStatusWarn - check.Score = 0 - 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 = 3 - 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") - } - - return check -} - -// generateTextConsistencyCheck creates a check for text/HTML consistency -func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Plain Text Consistency", - } - - consistency := results.TextPlainRatio - - if consistency < 0.3 { - check.Status = api.CheckStatusWarn - check.Score = 0 - 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) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 3 - 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) - check.Details = &details - } - - return check -} - -// generateImageRatioCheck creates a check for image-to-text ratio -func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Image-to-Text Ratio", - } - - ratio := results.ImageTextRatio - - // Flag if more than 1 image per 100 characters (very image-heavy) - if ratio > 10.0 { - check.Status = api.CheckStatusFail - check.Score = 0 - 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) - check.Details = &details - } else if ratio > 5.0 { - check.Status = api.CheckStatusWarn - check.Score = 2 - 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) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 3 - 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) - check.Details = &details - } - - return check -} - -// generateSuspiciousURLCheck creates a check for suspicious URLs -func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check { - check := api.Check{ - Category: api.Content, - Name: "Suspicious URLs", - } - - count := len(results.SuspiciousURLs) - - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Severity = api.PtrTo(api.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") - - if count <= 3 { - details := strings.Join(results.SuspiciousURLs, ", ") - check.Details = &details - } else { - details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3) - check.Details = &details - } - - return check -} - -// GetContentScore calculates the content score (0-20 points) -func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int { +// CalculateContentScore calculates the content score (0-20 points) +func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int { if results == nil { return 0 } diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index 0a1c710..78a27e9 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -28,7 +28,6 @@ import ( "testing" "time" - "git.happydns.org/happyDeliver/internal/api" "golang.org/x/net/html" ) @@ -608,453 +607,6 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) { } } -func TestGenerateHTMLValidityCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Valid HTML", - results: &ContentResults{ - HTMLValid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 2, - }, - { - name: "Invalid HTML", - results: &ContentResults{ - HTMLValid: false, - HTMLErrors: []string{"Parse error"}, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateHTMLValidityCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateLinkChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "All links valid", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Status: 200}, - {URL: "https://example.org", Valid: true, Status: 200}, - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 4, - }, - { - name: "Broken links", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - { - name: "Links with warnings", - results: &ContentResults{ - Links: []LinkCheck{ - {URL: "https://example.com", Valid: true, Warning: "Could not verify"}, - }, - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 3, - }, - { - name: "No links", - results: &ContentResults{}, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.generateLinkChecks(tt.results) - - if tt.name == "No links" { - if len(checks) != 0 { - t.Errorf("Expected no checks, got %d", len(checks)) - } - return - } - - if len(checks) == 0 { - t.Fatal("Expected at least one check") - } - - check := checks[0] - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - }) - } -} - -func TestGenerateImageChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "All images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, - {Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"}, - }, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: false}, - {Src: "img2.jpg", HasAlt: false}, - }, - }, - expectedStatus: api.CheckStatusFail, - }, - { - name: "Some images have alt", - results: &ContentResults{ - Images: []ImageCheck{ - {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, - {Src: "img2.jpg", HasAlt: false}, - }, - }, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.generateImageChecks(tt.results) - - if len(checks) == 0 { - t.Fatal("Expected at least one check") - } - - check := checks[0] - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateUnsubscribeCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "Has unsubscribe link", - results: &ContentResults{ - HasUnsubscribe: true, - UnsubscribeLinks: []string{"https://example.com/unsubscribe"}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No unsubscribe link", - results: &ContentResults{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateUnsubscribeCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - }) - } -} - -func TestGenerateTextConsistencyCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "High consistency", - results: &ContentResults{ - TextPlainRatio: 0.8, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "Low consistency", - results: &ContentResults{ - TextPlainRatio: 0.1, - }, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateTextConsistencyCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateImageRatioCheck(t *testing.T) { - tests := []struct { - name string - results *ContentResults - expectedStatus api.CheckStatus - }{ - { - name: "Reasonable ratio", - results: &ContentResults{ - ImageTextRatio: 3.0, - Images: []ImageCheck{{}, {}, {}}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "High ratio", - results: &ContentResults{ - ImageTextRatio: 7.0, - Images: make([]ImageCheck, 7), - }, - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Excessive ratio", - results: &ContentResults{ - ImageTextRatio: 15.0, - Images: make([]ImageCheck, 15), - }, - expectedStatus: api.CheckStatusFail, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateImageRatioCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateSuspiciousURLCheck(t *testing.T) { - results := &ContentResults{ - SuspiciousURLs: []string{ - "https://bit.ly/abc123", - "https://192.168.1.1/page", - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - check := analyzer.generateSuspiciousURLCheck(results) - - if check.Status != api.CheckStatusWarn { - t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn) - } - if check.Category != api.Content { - t.Errorf("Category = %v, want %v", check.Category, api.Content) - } - if !strings.Contains(check.Message, "2") { - t.Error("Message should mention the count of suspicious URLs") - } -} - -func TestGetContentScore(t *testing.T) { - tests := []struct { - name string - results *ContentResults - minScore int - maxScore int - }{ - { - name: "Nil results", - results: nil, - minScore: 0, - maxScore: 0, - }, - { - name: "Perfect content", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextPlainRatio: 0.8, - ImageTextRatio: 3.0, - }, - minScore: 90, - maxScore: 100, - }, - { - name: "Poor content", - results: &ContentResults{ - HTMLValid: false, - Links: []LinkCheck{{Valid: true, Status: 404}}, - Images: []ImageCheck{{HasAlt: false}}, - HasUnsubscribe: false, - TextPlainRatio: 0.1, - ImageTextRatio: 15.0, - SuspiciousURLs: []string{"url1", "url2"}, - }, - minScore: 0, - maxScore: 25, - }, - { - name: "Average content", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: false, - TextPlainRatio: 0.5, - ImageTextRatio: 4.0, - }, - minScore: 50, - maxScore: 90, - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := analyzer.GetContentScore(tt.results) - - if score < tt.minScore || score > tt.maxScore { - t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - - // Ensure score is capped at 100 - if score > 100 { - t.Errorf("Score %v exceeds maximum of 100", score) - } - - // Ensure score is not negative - if score < 0 { - t.Errorf("Score %v is negative", score) - } - }) - } -} - -func TestGenerateContentChecks(t *testing.T) { - tests := []struct { - name string - results *ContentResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "Complete results", - results: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextContent: "Plain text", - HTMLContent: "

    HTML text

    ", - ImageTextRatio: 3.0, - }, - minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio - }, - { - name: "With suspicious URLs", - results: &ContentResults{ - HTMLValid: true, - SuspiciousURLs: []string{"url1"}, - }, - minChecks: 3, // HTML, Unsubscribe, Suspicious URLs - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateContentChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Content category - for _, check := range checks { - if check.Category != api.Content { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content) - } - } - }) - } -} - // Helper functions for testing func parseHTML(htmlStr string) (*html.Node, error) { diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 1a03a99..0f7c111 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -492,232 +492,3 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool { return true } - -// GenerateDNSChecks generates check results for DNS validation -func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // MX record check - checks = append(checks, d.generateMXCheck(results)) - - // SPF record check - if results.SPFRecord != nil { - checks = append(checks, d.generateSPFCheck(results.SPFRecord)) - } - - // DKIM record checks - for _, dkim := range results.DKIMRecords { - checks = append(checks, d.generateDKIMCheck(&dkim)) - } - - // DMARC record check - if results.DMARCRecord != nil { - checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) - } - - // BIMI record check (optional) - if results.BIMIRecord != nil { - checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord)) - } - - return checks -} - -// generateMXCheck creates a check for MX records -func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "MX Records", - } - - if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { - check.Status = api.CheckStatusFail - check.Score = 0 - check.Severity = api.PtrTo(api.CheckSeverityCritical) - - if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { - check.Message = results.MXRecords[0].Error - } else { - check.Message = "No valid MX records found" - } - check.Advice = api.PtrTo("Configure MX records for your domain to receive email") - } else { - check.Status = api.CheckStatusPass - check.Score = 100 - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) - - // Add details about MX records - var mxList []string - for _, mx := range results.MXRecords { - mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority)) - } - details := strings.Join(mxList, ", ") - check.Details = &details - check.Advice = api.PtrTo("Your MX records are properly configured") - } - - return check -} - -// generateSPFCheck creates a check for SPF records -func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "SPF Record", - } - - if !spf.Valid { - if spf.Record == "" { - // If no record exists at all, it's a failure - check.Status = api.CheckStatusFail - check.Score = 0 - check.Message = spf.Error - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") - } else { - // If record exists but is invalid, it's a failure - check.Status = api.CheckStatusFail - check.Score = 5 - check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Review and fix your SPF record syntax") - check.Details = &spf.Record - } - } else { - check.Status = api.CheckStatusPass - check.Score = 100 - check.Message = "Valid SPF record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Details = &spf.Record - check.Advice = api.PtrTo("Your SPF record is properly configured") - } - - return check -} - -// generateDKIMCheck creates a check for DKIM records -func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector), - } - - if !dkim.Valid { - check.Status = api.CheckStatusFail - check.Score = 0 - check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - } else { - check.Status = api.CheckStatusPass - check.Score = 100 - check.Message = "Valid DKIM record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) - check.Details = &details - check.Advice = api.PtrTo("Your DKIM record is properly published") - } - - return check -} - -// generateDMARCCheck creates a check for DMARC records -func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { - check := api.Check{ - Category: api.Dns, - Name: "DMARC Record", - } - - if !dmarc.Valid { - check.Status = api.CheckStatusFail - check.Score = 0 - check.Message = dmarc.Error - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") - } else { - check.Status = api.CheckStatusPass - check.Score = 100 - check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Details = &dmarc.Record - - // Provide advice based on policy - switch dmarc.Policy { - case "none": - advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection" - check.Advice = &advice - case "quarantine": - advice := "DMARC policy is set to 'quarantine'. This provides good protection" - check.Advice = &advice - case "reject": - advice := "DMARC policy is set to 'reject'. This provides the strongest protection" - check.Advice = &advice - default: - advice := "Your DMARC record is properly configured" - check.Advice = &advice - } - } - - return check -} - -// generateBIMICheck creates a check for BIMI records -func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) 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 - check.Message = "No BIMI record found (optional)" - check.Severity = api.PtrTo(api.CheckSeverityLow) - if dmarc.Policy != "quarantine" && dmarc.Policy != "reject" { - 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 { - check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients.") - } - } else { - // If record exists but is invalid - check.Status = api.CheckStatusWarn - check.Score = 5 - 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 = 100 // BIMI doesn't contribute to score (branding feature) - check.Message = "Valid BIMI record found" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - - // Build details with logo and VMC URLs - var detailsParts []string - detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) - if bimi.LogoURL != "" { - detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) - } - if bimi.VMCURL != "" { - detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) - check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") - } else { - check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") - } - - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 750c620..7859523 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -23,11 +23,8 @@ package analyzer import ( "net/mail" - "strings" "testing" "time" - - "git.happydns.org/happyDeliver/internal/api" ) func TestNewDNSAnalyzer(t *testing.T) { @@ -300,338 +297,6 @@ func TestValidateDMARC(t *testing.T) { } } -func TestGenerateMXCheck(t *testing.T) { - tests := []struct { - name string - results *DNSResults - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Valid MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - {Host: "mail2.example.com", Priority: 20, Valid: true}, - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "No MX records", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "No MX records found"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - { - name: "MX lookup failed", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Valid: false, Error: "DNS lookup failed"}, - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateMXCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateSPFCheck(t *testing.T) { - tests := []struct { - name string - spf *SPFRecord - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Valid SPF", - spf: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "Invalid SPF", - spf: &SPFRecord{ - Record: "v=spf1 invalid syntax", - Valid: false, - Error: "SPF record appears malformed", - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 5, - }, - { - name: "No SPF record", - spf: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateSPFCheck(tt.spf) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - }) - } -} - -func TestGenerateDKIMCheck(t *testing.T) { - tests := []struct { - name string - dkim *DKIMRecord - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Valid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=MIGfMA0...", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "Invalid DKIM", - dkim: &DKIMRecord{ - Selector: "default", - Domain: "example.com", - Valid: false, - Error: "No DKIM record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDKIMCheck(tt.dkim) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - if !strings.Contains(check.Name, tt.dkim.Selector) { - t.Errorf("Check name should contain selector %s", tt.dkim.Selector) - } - }) - } -} - -func TestGenerateDMARCCheck(t *testing.T) { - tests := []struct { - name string - dmarc *DMARCRecord - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Valid DMARC - reject", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=reject", - Policy: "reject", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "Valid DMARC - quarantine", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "Valid DMARC - none", - dmarc: &DMARCRecord{ - Record: "v=DMARC1; p=none", - Policy: "none", - Valid: true, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 10, - }, - { - name: "No DMARC record", - dmarc: &DMARCRecord{ - Valid: false, - Error: "No DMARC record found", - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateDMARCCheck(tt.dmarc) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - - // Check that advice mentions policy for valid DMARC - if tt.dmarc.Valid && check.Advice != nil { - if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") { - t.Error("Advice should mention 'none' policy") - } - } - }) - } -} - -func TestGenerateDNSChecks(t *testing.T) { - tests := []struct { - name string - results *DNSResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "Complete results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Policy: "quarantine", - Valid: true, - }, - }, - minChecks: 4, // MX, SPF, DKIM, DMARC - }, - { - name: "Partial results", - results: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - }, - minChecks: 1, // Only MX - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateDNSChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the DNS category - for _, check := range checks { - if check.Category != api.Dns { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns) - } - } - }) - } -} - -func TestAnalyzeDNS_NoDomain(t *testing.T) { - analyzer := NewDNSAnalyzer(5 * time.Second) - email := &EmailMessage{ - Header: make(mail.Header), - // No From address - } - - results := analyzer.AnalyzeDNS(email, nil) - - if results == nil { - t.Fatal("Expected results, got nil") - } - - if len(results.Errors) == 0 { - t.Error("Expected error when no domain can be extracted") - } -} - func TestExtractBIMITag(t *testing.T) { tests := []struct { name string @@ -732,89 +397,3 @@ func TestValidateBIMI(t *testing.T) { }) } } - -func TestGenerateBIMICheck(t *testing.T) { - tests := []struct { - name string - bimi *BIMIRecord - expectedStatus api.CheckStatus - expectedScore int - }{ - { - 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, // 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, - }, - { - name: "No BIMI record (optional)", - bimi: &BIMIRecord{ - Selector: "default", - Domain: "example.com", - Valid: false, - Error: "No BIMI record found", - }, - expectedStatus: api.CheckStatusInfo, - expectedScore: 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, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateBIMICheck(tt.bimi) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Dns { - t.Errorf("Category = %v, want %v", check.Category, api.Dns) - } - if check.Name != "BIMI Record" { - t.Errorf("Name = %q, want %q", check.Name, "BIMI Record") - } - - // Check details for valid BIMI with VMC - if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil { - if !strings.Contains(*check.Details, "VMC URL") { - t.Error("Details should contain VMC URL for valid BIMI with VMC") - } - } - }) - } -} diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 7fa252a..4ffc1a3 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -36,56 +36,53 @@ func NewHeaderAnalyzer() *HeaderAnalyzer { return &HeaderAnalyzer{} } -// calculateHeaderScore evaluates email structural quality -func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int { - if email == nil { +// CalculateHeaderScore evaluates email structural quality from header analysis +func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int { + if analysis == nil || analysis.Headers == nil { return 0 } score := 0 - requiredHeaders := 0 - presentHeaders := 0 + headers := *analysis.Headers - // Check required headers (RFC 5322) - headers := map[string]bool{ - "From": false, - "Date": false, - "Message-ID": false, - } + // Check required headers (RFC 5322) - 40 points + requiredHeaders := []string{"from", "date", "message-id"} + requiredCount := len(requiredHeaders) + presentRequired := 0 - for header := range headers { - requiredHeaders++ - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - headers[header] = true - presentHeaders++ + for _, headerName := range requiredHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRequired++ } } - // Score based on required headers (40 points) - if presentHeaders == requiredHeaders { + if presentRequired == requiredCount { score += 40 } else { - score += int(40 * (float32(presentHeaders) / float32(requiredHeaders))) + score += int(40 * (float32(presentRequired) / float32(requiredCount))) } // Check recommended headers (30 points) - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - recommendedPresent := 0 - for _, header := range recommendedHeaders { - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - recommendedPresent++ + recommendedHeaders := []string{"subject", "to", "reply-to"} + recommendedCount := len(recommendedHeaders) + presentRecommended := 0 + + for _, headerName := range recommendedHeaders { + if check, exists := headers[headerName]; exists && check.Present { + presentRecommended++ } } - score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))) + score += int(30 * (float32(presentRecommended) / float32(recommendedCount))) // Check for proper MIME structure (20 points) - if len(email.Parts) > 0 { + if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { score += 20 } - // Check Message-ID format (10 point) - if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { - if h.isValidMessageID(messageID) { + // Check Message-ID format (10 points) + if check, exists := headers["message-id"]; exists && check.Present { + // If Valid is set and true, award points + if check.Valid != nil && *check.Valid { score += 10 } } @@ -123,181 +120,187 @@ func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { return len(parts[0]) > 0 && len(parts[1]) > 0 } -// GenerateHeaderChecks creates checks for email header quality -func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check { - var checks []api.Check - +// GenerateHeaderAnalysis creates structured header analysis from email +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { if email == nil { - return checks + return nil } - // Required headers check - checks = append(checks, h.generateRequiredHeadersCheck(email)) + analysis := &api.HeaderAnalysis{} - // Recommended headers check - checks = append(checks, h.generateRecommendedHeadersCheck(email)) + // Check for proper MIME structure + analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0) - // Message-ID check - checks = append(checks, h.generateMessageIDCheck(email)) + // Initialize headers map + headers := make(map[string]api.HeaderCheck) - // MIME structure check - checks = append(checks, h.generateMIMEStructureCheck(email)) + // Check required headers + requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"} + for _, headerName := range requiredHeaders { + check := h.checkHeader(email, headerName, "required") + headers[strings.ToLower(headerName)] = *check + } - return checks + // Check recommended headers + recommendedHeaders := []string{"Reply-To", "Return-Path"} + for _, headerName := range recommendedHeaders { + check := h.checkHeader(email, headerName, "recommended") + headers[strings.ToLower(headerName)] = *check + } + + // Check optional headers + optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post", "Precedence"} + for _, headerName := range optionalHeaders { + check := h.checkHeader(email, headerName, "optional") + headers[strings.ToLower(headerName)] = *check + } + + analysis.Headers = &headers + + // Domain alignment + domainAlignment := h.analyzeDomainAlignment(email) + if domainAlignment != nil { + analysis.DomainAlignment = domainAlignment + } + + // Header issues + issues := h.findHeaderIssues(email) + if len(issues) > 0 { + analysis.Issues = &issues + } + + return analysis } -// generateRequiredHeadersCheck checks for required RFC 5322 headers -func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Required Headers", +// checkHeader checks if a header is present and valid +func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck { + value := email.GetHeaderValue(headerName) + present := email.HasHeader(headerName) && value != "" + + importanceEnum := api.HeaderCheckImportance(importance) + check := &api.HeaderCheck{ + Present: present, + Importance: &importanceEnum, } - requiredHeaders := []string{"From", "Date", "Message-ID"} - missing := []string{} + if present { + check.Value = &value + // Validate specific headers + valid := true + var headerIssues []string + + switch headerName { + case "Message-ID": + if !h.isValidMessageID(value) { + valid = false + headerIssues = append(headerIssues, "Invalid Message-ID format (should be )") + } + case "Date": + // Could add date validation here + } + + check.Valid = &valid + if len(headerIssues) > 0 { + check.Issues = &headerIssues + } + } else { + valid := false + check.Valid = &valid + if importance == "required" { + issues := []string{"Required header is missing"} + check.Issues = &issues + } + } + + return check +} + +// analyzeDomainAlignment checks domain alignment between headers +func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { + alignment := &api.DomainAlignment{ + Aligned: api.PtrTo(true), + } + + // Extract From domain + fromAddr := email.GetHeaderValue("From") + if fromAddr != "" { + domain := h.extractDomain(fromAddr) + if domain != "" { + alignment.FromDomain = &domain + } + } + + // Extract Return-Path domain + returnPath := email.GetHeaderValue("Return-Path") + if returnPath != "" { + domain := h.extractDomain(returnPath) + if domain != "" { + alignment.ReturnPathDomain = &domain + } + } + + // Check alignment + issues := []string{} + if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { + if *alignment.FromDomain != *alignment.ReturnPathDomain { + *alignment.Aligned = false + issues = append(issues, "Return-Path domain does not match From domain") + } + } + + if len(issues) > 0 { + alignment.Issues = &issues + } + + return alignment +} + +// extractDomain extracts domain from email address +func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { + // Remove angle brackets if present + emailAddr = strings.Trim(emailAddr, "<> ") + + // Find @ symbol + atIndex := strings.LastIndex(emailAddr, "@") + if atIndex == -1 { + return "" + } + + domain := emailAddr[atIndex+1:] + // Remove any trailing > + domain = strings.TrimRight(domain, ">") + + return domain +} + +// findHeaderIssues identifies issues with headers +func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { + var issues []api.HeaderIssue + + // Check for missing required headers + requiredHeaders := []string{"From", "Date", "Message-ID"} for _, header := range requiredHeaders { if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) + issues = append(issues, api.HeaderIssue{ + Header: header, + Severity: api.HeaderIssueSeverityCritical, + Message: fmt.Sprintf("Required header '%s' is missing", header), + Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)), + }) } } - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 4.0 - check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) - 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.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } - - return check -} - -// generateRecommendedHeadersCheck checks for recommended headers -func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Recommended Headers", - } - - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - missing := []string{} - - for _, header := range recommendedHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 30 - check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) - 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 = 15 - check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } else { - check.Status = api.CheckStatusWarn - check.Score = 0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Missing all recommended headers" - check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") - } - - return check -} - -// generateMessageIDCheck validates Message-ID header -func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Message-ID Format", - } - + // Check Message-ID format messageID := email.GetHeaderValue("Message-ID") - - if messageID == "" { - check.Status = api.CheckStatusFail - check.Score = 0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Message = "Message-ID header is missing" - check.Advice = api.PtrTo("Add a unique Message-ID header to your email") - } else if !h.isValidMessageID(messageID) { - check.Status = api.CheckStatusWarn - check.Score = 5 - check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) - 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 = 10 - check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) - 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 + if messageID != "" && !h.isValidMessageID(messageID) { + issues = append(issues, api.HeaderIssue{ + Header: "Message-ID", + Severity: api.HeaderIssueSeverityMedium, + Message: "Message-ID format is invalid", + Advice: api.PtrTo("Use proper Message-ID format: "), + }) } - return check -} - -// generateMIMEStructureCheck validates MIME structure -func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "MIME Structure", - } - - if len(email.Parts) == 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = "No MIME parts detected" - check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") - } else { - check.Status = api.CheckStatusPass - check.Score = 2.0 - check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) - check.Advice = api.PtrTo("Your email has proper MIME structure") - - // Add details about parts - partTypes := []string{} - for _, part := range email.Parts { - if part.ContentType != "" { - partTypes = append(partTypes, part.ContentType) - } - } - if len(partTypes) > 0 { - details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) - check.Details = &details - } - } - - return check + return issues } diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 8594f7f..418b553 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -25,8 +25,6 @@ import ( "net/mail" "net/textproto" "testing" - - "git.happydns.org/happyDeliver/internal/api" ) func TestCalculateHeaderScore(t *testing.T) { @@ -109,95 +107,70 @@ func TestCalculateHeaderScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := analyzer.calculateHeaderScore(tt.email) + // Generate header analysis first + analysis := analyzer.GenerateHeaderAnalysis(tt.email) + score := analyzer.CalculateHeaderScore(analysis) if score < tt.minScore || score > tt.maxScore { - t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } }) } } -func TestGenerateRequiredHeadersCheck(t *testing.T) { +func TestCheckHeader(t *testing.T) { tests := []struct { - name string - email *EmailMessage - expectedStatus api.CheckStatus - expectedScore int + name string + headerName string + headerValue string + importance string + expectedPresent bool + expectedValid bool + expectedIssuesLen int }{ { - name: "All required headers present", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - From: &mail.Address{Address: "sender@example.com"}, - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 40, + name: "Valid Message-ID", + headerName: "Message-ID", + headerValue: "", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, }, { - name: "Missing all required headers", - email: &EmailMessage{ - Header: make(mail.Header), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, + name: "Invalid Message-ID format", + headerName: "Message-ID", + headerValue: "invalid-message-id", + importance: "required", + expectedPresent: true, + expectedValid: false, + expectedIssuesLen: 1, }, { - name: "Missing some required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - analyzer := NewHeaderAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateRequiredHeadersCheck(tt.email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMessageIDCheck(t *testing.T) { - tests := []struct { - name string - messageID string - expectedStatus api.CheckStatus - }{ - { - name: "Valid Message-ID", - messageID: "", - expectedStatus: api.CheckStatusPass, + name: "Missing required header", + headerName: "From", + headerValue: "", + importance: "required", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 1, }, { - name: "Invalid Message-ID format", - messageID: "invalid-message-id", - expectedStatus: api.CheckStatusWarn, + name: "Missing optional header", + headerName: "Reply-To", + headerValue: "", + importance: "optional", + expectedPresent: false, + expectedValid: false, + expectedIssuesLen: 0, }, { - name: "Missing Message-ID", - messageID: "", - expectedStatus: api.CheckStatusFail, + name: "Valid Date header", + headerName: "Date", + headerValue: "Mon, 01 Jan 2024 12:00:00 +0000", + importance: "required", + expectedPresent: true, + expectedValid: true, + expectedIssuesLen: 0, }, } @@ -207,86 +180,77 @@ func TestGenerateMessageIDCheck(t *testing.T) { t.Run(tt.name, func(t *testing.T) { email := &EmailMessage{ Header: createHeaderWithFields(map[string]string{ - "Message-ID": tt.messageID, + tt.headerName: tt.headerValue, }), } - check := analyzer.generateMessageIDCheck(email) + check := analyzer.checkHeader(email, tt.headerName, tt.importance) - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + if check.Present != tt.expectedPresent { + t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent) } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + if check.Importance == nil { + t.Error("Importance is nil") + } else if string(*check.Importance) != tt.importance { + t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen) } }) } } -func TestGenerateMIMEStructureCheck(t *testing.T) { - tests := []struct { - name string - parts []MessagePart - expectedStatus api.CheckStatus - }{ - { - name: "With MIME parts", - parts: []MessagePart{ - {ContentType: "text/plain", Content: "test"}, - {ContentType: "text/html", Content: "

    test

    "}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No MIME parts", - parts: []MessagePart{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - analyzer := NewHeaderAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - Parts: tt.parts, - } - - check := analyzer.generateMIMEStructureCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateHeaderChecks(t *testing.T) { +func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) { tests := []struct { name string - email *EmailMessage - minChecks int + messageID string + expected bool }{ { - name: "Nil email", - email: nil, - minChecks: 0, + name: "Valid Message-ID", + messageID: "", + expected: true, }, { - name: "Complete email", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minChecks: 4, // Required, Recommended, Message-ID, MIME + name: "Valid with complex local part", + messageID: "", + expected: true, + }, + { + name: "Missing angle brackets", + messageID: "abc123@example.com", + expected: false, + }, + { + name: "Missing @ symbol", + messageID: "", + expected: false, + }, + { + name: "Empty local part", + messageID: "<@example.com>", + expected: false, + }, + { + name: "Empty domain", + messageID: "", + expected: false, + }, + { + name: "Multiple @ symbols", + messageID: "", + expected: false, }, } @@ -294,17 +258,126 @@ func TestGenerateHeaderChecks(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateHeaderChecks(tt.email) + result := analyzer.isValidMessageID(tt.messageID) + if result != tt.expected { + t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) + } + }) + } +} - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) +func TestHeaderAnalyzer_ExtractDomain(t *testing.T) { + tests := []struct { + name string + email string + expected string + }{ + { + name: "Simple email", + email: "user@example.com", + expected: "example.com", + }, + { + name: "Email with angle brackets", + email: "", + expected: "example.com", + }, + { + name: "Email with display name", + email: "User Name ", + expected: "example.com", + }, + { + name: "Email with spaces", + email: " user@example.com ", + expected: "example.com", + }, + { + name: "Invalid email", + email: "not-an-email", + expected: "", + }, + { + name: "Empty string", + email: "", + expected: "", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDomain(tt.email) + if result != tt.expected { + t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected) + } + }) + } +} + +func TestAnalyzeDomainAlignment(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + expectAligned bool + expectIssuesLen int + }{ + { + name: "Aligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + expectAligned: true, + expectIssuesLen: 0, + }, + { + name: "Misaligned domains", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + expectAligned: false, + expectIssuesLen: 1, + }, + { + name: "Only From header", + fromHeader: "sender@example.com", + returnPath: "", + expectAligned: true, + expectIssuesLen: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), } - // Verify all checks have the Headers category - for _, check := range checks { - if check.Category != api.Headers { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) - } + alignment := analyzer.analyzeDomainAlignment(email) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned) + } + + issuesLen := 0 + if alignment.Issues != nil { + issuesLen = len(*alignment.Issues) + } + if issuesLen != tt.expectIssuesLen { + t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen) } }) } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index f13e681..aa35281 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -68,24 +68,15 @@ func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { // RBLResults represents the results of RBL checks type RBLResults struct { - Checks map[string][]RBLCheck // Map of IP -> list of RBL checks for that IP + Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP IPsChecked []string ListedCount int } -// RBLCheck represents a single RBL check result -// Note: IP is not included here as it's used as the map key in the API -type RBLCheck struct { - RBL string - Listed bool - Response string - Error string -} - // CheckEmail checks all IPs found in the email headers against RBLs func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results := &RBLResults{ - Checks: make(map[string][]RBLCheck), + Checks: make(map[string][]api.BlacklistCheck), } // Extract IPs from Received headers @@ -179,15 +170,15 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { } // checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { - check := RBLCheck{ - RBL: rbl, +func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { + check := api.BlacklistCheck{ + Rbl: rbl, } // Reverse the IP for DNSBL query reversedIP := r.reverseIP(ip) if reversedIP == "" { - check.Error = "Failed to reverse IP address" + check.Error = api.PtrTo("Failed to reverse IP address") return check } @@ -208,19 +199,19 @@ func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { } } // Other DNS errors - check.Error = fmt.Sprintf("DNS lookup failed: %v", err) + check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) return check } // If we got a response, check the return code if len(addrs) > 0 { - check.Response = addrs[0] // Return code (e.g., 127.0.0.2) + check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2) // Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 // These indicate RBL operational issues, not actual listings if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" { check.Listed = false - check.Error = fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]) + check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])) } else { // Normal listing response check.Listed = true @@ -248,8 +239,8 @@ func (r *RBLChecker) reverseIP(ipStr string) string { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// GetBlacklistScore calculates the blacklist contribution to deliverability -func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int { +// CalculateRBLScore calculates the blacklist contribution to deliverability +func (r *RBLChecker) CalculateRBLScore(results *RBLResults) int { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt return 100 @@ -258,159 +249,6 @@ func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int { return 100 - results.ListedCount*100/len(r.RBLs) } -// GenerateRBLChecks generates check results for RBL analysis -func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { - var checks []api.Check - - if results == nil { - return checks - } - - // If no IPs were checked, add a warning - if len(results.IPsChecked) == 0 { - checks = append(checks, api.Check{ - Category: api.Blacklist, - Name: "RBL Check", - Status: api.CheckStatusWarn, - Score: 50, - Grade: ScoreToCheckGrade(50), - Message: "No public IP addresses found to check", - Severity: api.PtrTo(api.CheckSeverityLow), - Advice: api.PtrTo("Unable to extract sender IP from email headers"), - }) - return checks - } - - // Create a summary check - summaryCheck := r.generateSummaryCheck(results) - checks = append(checks, summaryCheck) - - // Create individual checks for each listing and RBL errors - for ip, rblChecks := range results.Checks { - for _, check := range rblChecks { - if check.Listed { - detailCheck := r.generateListingCheck(ip, &check) - checks = append(checks, detailCheck) - } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { - // Generate info check for RBL errors - detailCheck := r.generateRBLErrorCheck(ip, &check) - checks = append(checks, detailCheck) - } - } - } - - return checks -} - -// generateSummaryCheck creates an overall RBL summary check -func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: "RBL Summary", - } - - score := r.GetBlacklistScore(results) - check.Score = score - check.Grade = ScoreToCheckGrade(score) - - // Calculate total checks across all IPs - totalChecks := 0 - for _, rblChecks := range results.Checks { - totalChecks += len(rblChecks) - } - listedCount := results.ListedCount - - if listedCount == 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs)) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your sending IP has a good reputation") - } else if listedCount == 1 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") - } else if listedCount <= 3 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") - } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") - } - - // Add details about IPs checked - if len(results.IPsChecked) > 0 { - details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", ")) - check.Details = &details - } - - return check -} - -// generateListingCheck creates a check for a specific RBL listing -func (r *RBLChecker) generateListingCheck(ip string, rblCheck *RBLCheck) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), - Status: api.CheckStatusFail, - Score: 0, - Grade: ScoreToCheckGrade(0), - } - - check.Message = fmt.Sprintf("IP %s is listed on %s", ip, rblCheck.RBL) - - // Determine severity based on which RBL - if strings.Contains(rblCheck.RBL, "spamhaus") { - check.Severity = api.PtrTo(api.CheckSeverityCritical) - advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") - check.Advice = &advice - } else if strings.Contains(rblCheck.RBL, "spamcop") { - check.Severity = api.PtrTo(api.CheckSeverityHigh) - advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") - check.Advice = &advice - } else { - check.Severity = api.PtrTo(api.CheckSeverityHigh) - advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) - check.Advice = &advice - } - - // Add response code details - if rblCheck.Response != "" { - details := fmt.Sprintf("Response: %s", rblCheck.Response) - check.Details = &details - } - - return check -} - -// generateRBLErrorCheck creates an info-level check for RBL operational errors -func (r *RBLChecker) generateRBLErrorCheck(ip string, rblCheck *RBLCheck) api.Check { - check := api.Check{ - Category: api.Blacklist, - Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), - Status: api.CheckStatusInfo, - Score: 0, // No penalty for RBL operational issues - Grade: ScoreToCheckGrade(-1), - Severity: api.PtrTo(api.CheckSeverityInfo), - } - - check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, ip) - - advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response) - check.Advice = &advice - - if rblCheck.Response != "" { - details := fmt.Sprintf("Error code: %s (RBL operational issue, not a listing)", rblCheck.Response) - check.Details = &details - } - - return check -} - // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { var listedIPs []string @@ -434,7 +272,7 @@ func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { if rblChecks, exists := results.Checks[ip]; exists { for _, check := range rblChecks { if check.Listed { - rbls = append(rbls, check.RBL) + rbls = append(rbls, check.Rbl) } } } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 2bd5c35..33c4ed5 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -23,7 +23,6 @@ package analyzer import ( "net/mail" - "strings" "testing" "time" @@ -327,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := checker.GetBlacklistScore(tt.results) + score := checker.CalculateRBLScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -335,225 +334,19 @@ func TestGetBlacklistScore(t *testing.T) { } } -func TestGenerateSummaryCheck(t *testing.T) { - tests := []struct { - name string - results *RBLResults - expectedStatus api.CheckStatus - expectedScore int - }{ - { - name: "Not listed", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: map[string][]RBLCheck{ - "198.51.100.1": make([]RBLCheck, 6), // 6 default RBLs - }, - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 200, - }, - { - name: "Listed on 1 RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, - Checks: map[string][]RBLCheck{ - "198.51.100.1": make([]RBLCheck, 6), - }, - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 100, - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: map[string][]RBLCheck{ - "198.51.100.1": make([]RBLCheck, 6), - }, - }, - expectedStatus: api.CheckStatusWarn, - expectedScore: 50, - }, - { - name: "Listed on 4+ RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, - Checks: map[string][]RBLCheck{ - "198.51.100.1": make([]RBLCheck, 6), - }, - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateSummaryCheck(tt.results) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Score != tt.expectedScore { - t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - }) - } -} - -func TestGenerateListingCheck(t *testing.T) { - tests := []struct { - name string - rblCheck *RBLCheck - expectedStatus api.CheckStatus - expectedSeverity api.CheckSeverity - }{ - { - name: "Spamhaus listing", - rblCheck: &RBLCheck{ - RBL: "zen.spamhaus.org", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityCritical, - }, - { - name: "SpamCop listing", - rblCheck: &RBLCheck{ - RBL: "bl.spamcop.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, - }, - { - name: "Other RBL listing", - rblCheck: &RBLCheck{ - RBL: "dnsbl.sorbs.net", - Listed: true, - Response: "127.0.0.2", - }, - expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := checker.generateListingCheck("198.51.100.1", tt.rblCheck) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Severity == nil || *check.Severity != tt.expectedSeverity { - t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity) - } - if check.Category != api.Blacklist { - t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) - } - if !strings.Contains(check.Name, tt.rblCheck.RBL) { - t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL) - } - }) - } -} - -func TestGenerateRBLChecks(t *testing.T) { - tests := []struct { - name string - results *RBLResults - minChecks int - }{ - { - name: "Nil results", - results: nil, - minChecks: 0, - }, - { - name: "No IPs checked", - results: &RBLResults{ - IPsChecked: []string{}, - }, - minChecks: 1, // Warning check - }, - { - name: "Not listed on any RBL", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 0, - Checks: map[string][]RBLCheck{ - "198.51.100.1": { - {RBL: "zen.spamhaus.org", Listed: false}, - {RBL: "bl.spamcop.net", Listed: false}, - }, - }, - }, - minChecks: 1, // Summary check only - }, - { - name: "Listed on 2 RBLs", - results: &RBLResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - Checks: map[string][]RBLCheck{ - "198.51.100.1": { - {RBL: "zen.spamhaus.org", Listed: true}, - {RBL: "bl.spamcop.net", Listed: true}, - {RBL: "dnsbl.sorbs.net", Listed: false}, - }, - }, - }, - minChecks: 3, // Summary + 2 listing checks - }, - } - - checker := NewRBLChecker(5*time.Second, nil) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := checker.GenerateRBLChecks(tt.results) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Blacklist category - for _, check := range checks { - if check.Category != api.Blacklist { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist) - } - } - }) - } -} - func TestGetUniqueListedIPs(t *testing.T) { results := &RBLResults{ - Checks: map[string][]RBLCheck{ + Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { - {RBL: "zen.spamhaus.org", Listed: true}, - {RBL: "bl.spamcop.net", Listed: true}, + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, }, "198.51.100.2": { - {RBL: "zen.spamhaus.org", Listed: true}, - {RBL: "bl.spamcop.net", Listed: false}, + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: false}, }, "198.51.100.3": { - {RBL: "zen.spamhaus.org", Listed: false}, + {Rbl: "zen.spamhaus.org", Listed: false}, }, }, } @@ -571,14 +364,14 @@ func TestGetUniqueListedIPs(t *testing.T) { func TestGetRBLsForIP(t *testing.T) { results := &RBLResults{ - Checks: map[string][]RBLCheck{ + Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { - {RBL: "zen.spamhaus.org", Listed: true}, - {RBL: "bl.spamcop.net", Listed: true}, - {RBL: "dnsbl.sorbs.net", Listed: false}, + {Rbl: "zen.spamhaus.org", Listed: true}, + {Rbl: "bl.spamcop.net", Listed: true}, + {Rbl: "dnsbl.sorbs.net", Listed: false}, }, "198.51.100.2": { - {RBL: "zen.spamhaus.org", Listed: true}, + {Rbl: "zen.spamhaus.org", Listed: true}, }, }, } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 78a0b5e..135dee1 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -61,10 +61,11 @@ func NewReportGenerator( type AnalysisResults struct { Email *EmailMessage Authentication *api.AuthenticationResults - SpamAssassin *SpamAssassinResult - DNS *DNSResults - RBL *RBLResults Content *ContentResults + DNS *DNSResults + Headers *api.HeaderAnalysis + RBL *RBLResults + SpamAssassin *SpamAssassinResult } // AnalyzeEmail performs complete email analysis @@ -75,10 +76,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) - results.RBL = r.rblChecker.CheckEmail(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email) + results.RBL = r.rblChecker.CheckEmail(email) + results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) return results } @@ -94,77 +96,65 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu CreatedAt: now, } - // Collect all checks from different analyzers - checks := []api.Check{} - - // Authentication checks + // Calculate scores directly from analyzers (no more checks array) + authScore := 0 if results.Authentication != nil { - authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication) - checks = append(checks, authChecks...) + authScore = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) } - // DNS checks - if results.DNS != nil { - dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS) - checks = append(checks, dnsChecks...) - } - - // RBL checks - if results.RBL != nil { - rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL) - checks = append(checks, rblChecks...) - } - - // SpamAssassin checks - if results.SpamAssassin != nil { - spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin) - checks = append(checks, spamChecks...) - } - - // Content checks + contentScore := 0 if results.Content != nil { - contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content) - checks = append(checks, contentChecks...) + contentScore = r.contentAnalyzer.CalculateContentScore(results.Content) } - // Header checks - headerChecks := r.headerAnalyzer.GenerateHeaderChecks(results.Email) - checks = append(checks, headerChecks...) - - report.Checks = checks - - // Summarize scores by category - categoryCounts := make(map[api.CheckCategory]int) - categoryTotals := make(map[api.CheckCategory]int) - - for _, check := range checks { - if check.Status == "info" { - continue - } - - categoryCounts[check.Category]++ - categoryTotals[check.Category] += check.Score + headerScore := 0 + if results.Headers != nil { + headerScore = r.headerAnalyzer.CalculateHeaderScore(results.Headers) } - // Calculate mean scores for each category - calcCategoryScore := func(category api.CheckCategory) int { - if count := categoryCounts[category]; count > 0 { - return categoryTotals[category] / count - } - return 0 + blacklistScore := 0 + if results.RBL != nil { + blacklistScore = r.rblChecker.CalculateRBLScore(results.RBL) + } + + spamScore := 0 + if results.SpamAssassin != nil { + spamScore = r.scorer.CalculateSpamScore(results.SpamAssassin) } report.Summary = &api.ScoreSummary{ - AuthenticationScore: calcCategoryScore(api.Authentication), - BlacklistScore: calcCategoryScore(api.Blacklist), - ContentScore: calcCategoryScore(api.Content), - HeaderScore: calcCategoryScore(api.Headers), - SpamScore: calcCategoryScore(api.Spam), + AuthenticationScore: authScore, + BlacklistScore: blacklistScore, + ContentScore: contentScore, + HeaderScore: headerScore, + SpamScore: spamScore, } // Add authentication results report.Authentication = results.Authentication + // Add content analysis + if results.Content != nil { + contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content) + report.ContentAnalysis = contentAnalysis + } + + // Add DNS records + if results.DNS != nil { + dnsRecords := r.buildDNSRecords(results.DNS) + if len(dnsRecords) > 0 { + report.DnsRecords = &dnsRecords + } + } + + // Add headers results + report.HeaderAnalysis = results.Headers + + // Add blacklist checks as a map of IP -> array of BlacklistCheck + if results.RBL != nil && len(results.RBL.Checks) > 0 { + report.Blacklists = &results.RBL.Checks + } + // Add SpamAssassin result if results.SpamAssassin != nil { report.Spamassassin = &api.SpamAssassinResult{ @@ -182,39 +172,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } } - // Add DNS records - if results.DNS != nil { - dnsRecords := r.buildDNSRecords(results.DNS) - if len(dnsRecords) > 0 { - report.DnsRecords = &dnsRecords - } - } - - // Add blacklist checks as a map of IP -> array of BlacklistCheck - if results.RBL != nil && len(results.RBL.Checks) > 0 { - blacklistMap := make(map[string][]api.BlacklistCheck) - - // Convert internal RBL checks to API format - for ip, rblChecks := range results.RBL.Checks { - apiChecks := make([]api.BlacklistCheck, 0, len(rblChecks)) - for _, check := range rblChecks { - blCheck := api.BlacklistCheck{ - Rbl: check.RBL, - Listed: check.Listed, - } - if check.Response != "" { - blCheck.Response = &check.Response - } - apiChecks = append(apiChecks, blCheck) - } - blacklistMap[ip] = apiChecks - } - - if len(blacklistMap) > 0 { - report.Blacklists = &blacklistMap - } - } - // Add raw headers if results.Email != nil && results.Email.RawHeaders != "" { report.RawHeaders = &results.Email.RawHeaders diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 85edcd2..b3827bc 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -24,11 +24,9 @@ package analyzer import ( "net/mail" "net/textproto" - "strings" "testing" "time" - "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/utils" "github.com/google/uuid" ) @@ -54,9 +52,6 @@ func TestNewReportGenerator(t *testing.T) { if gen.contentAnalyzer == nil { t.Error("contentAnalyzer should not be nil") } - if gen.scorer == nil { - t.Error("scorer should not be nil") - } } func TestAnalyzeEmail(t *testing.T) { @@ -77,20 +72,6 @@ func TestAnalyzeEmail(t *testing.T) { if results.Authentication == nil { t.Error("Authentication should not be nil") } - - // SpamAssassin might be nil if headers don't exist - // DNS results should exist - // RBL results should exist - // Content results should exist - - if results.Score == nil { - t.Error("Score should not be nil") - } - - // Verify score is within bounds - if results.Score.OverallScore < 0 || results.Score.OverallScore > 100 { - t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) - } } func TestGenerateReport(t *testing.T) { @@ -125,10 +106,6 @@ func TestGenerateReport(t *testing.T) { t.Error("Summary should not be nil") } - if len(report.Checks) == 0 { - t.Error("Checks should not be empty") - } - // Verify score summary if report.Summary != nil { if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { @@ -147,22 +124,6 @@ func TestGenerateReport(t *testing.T) { t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) } } - - // Verify checks have required fields - for i, check := range report.Checks { - if string(check.Category) == "" { - t.Errorf("Check %d: Category should not be empty", i) - } - if check.Name == "" { - t.Errorf("Check %d: Name should not be empty", i) - } - if string(check.Status) == "" { - t.Errorf("Check %d: Status should not be empty", i) - } - if check.Message == "" { - t.Errorf("Check %d: Message should not be empty", i) - } - } } func TestGenerateReportWithSpamAssassin(t *testing.T) { @@ -185,99 +146,6 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } } -func TestBuildDNSRecords(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - dns *DNSResults - expectedCount int - expectTypes []api.DNSRecordRecordType - }{ - { - name: "Nil DNS results", - dns: nil, - expectedCount: 0, - }, - { - name: "Complete DNS results", - dns: &DNSResults{ - Domain: "example.com", - MXRecords: []MXRecord{ - {Host: "mail.example.com", Priority: 10, Valid: true}, - }, - SPFRecord: &SPFRecord{ - Record: "v=spf1 include:_spf.example.com -all", - Valid: true, - }, - DKIMRecords: []DKIMRecord{ - { - Selector: "default", - Domain: "example.com", - Record: "v=DKIM1; k=rsa; p=...", - Valid: true, - }, - }, - DMARCRecord: &DMARCRecord{ - Record: "v=DMARC1; p=quarantine", - Valid: true, - }, - }, - expectedCount: 4, // MX, SPF, DKIM, DMARC - expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC}, - }, - { - name: "Missing records", - dns: &DNSResults{ - Domain: "example.com", - SPFRecord: &SPFRecord{ - Valid: false, - Error: "No SPF record found", - }, - }, - expectedCount: 1, - expectTypes: []api.DNSRecordRecordType{api.SPF}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - records := gen.buildDNSRecords(tt.dns) - - if len(records) != tt.expectedCount { - t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount) - } - - // Verify expected types are present - if tt.expectTypes != nil { - foundTypes := make(map[api.DNSRecordRecordType]bool) - for _, record := range records { - foundTypes[record.RecordType] = true - } - - for _, expectedType := range tt.expectTypes { - if !foundTypes[expectedType] { - t.Errorf("Expected DNS record type %s not found", expectedType) - } - } - } - - // Verify all records have required fields - for i, record := range records { - if record.Domain == "" { - t.Errorf("Record %d: Domain should not be empty", i) - } - if string(record.RecordType) == "" { - t.Errorf("Record %d: RecordType should not be empty", i) - } - if string(record.Status) == "" { - t.Errorf("Record %d: Status should not be empty", i) - } - } - }) - } -} - func TestGenerateRawEmail(t *testing.T) { gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) @@ -319,135 +187,6 @@ func TestGenerateRawEmail(t *testing.T) { } } -func TestGetRecommendations(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectCount int - }{ - { - name: "Nil results", - results: nil, - expectCount: 0, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 50, - Grade: ScoreToReportGrade(50), - AuthScore: 15, - SpamScore: 10, - BlacklistScore: 15, - ContentScore: 5, - HeaderScore: 5, - Recommendations: []string{ - "Improve authentication", - "Fix content issues", - }, - }, - }, - expectCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recs := gen.GetRecommendations(tt.results) - if len(recs) != tt.expectCount { - t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount) - } - }) - } -} - -func TestGetScoreSummaryText(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - - tests := []struct { - name string - results *AnalysisResults - expectEmpty bool - expectString string - }{ - { - name: "Nil results", - results: nil, - expectEmpty: true, - }, - { - name: "Results with score", - results: &AnalysisResults{ - Score: &ScoringResult{ - OverallScore: 85, - Grade: ScoreToReportGrade(85), - AuthScore: 25, - SpamScore: 18, - BlacklistScore: 20, - ContentScore: 15, - HeaderScore: 7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 25, Status: "Pass"}, - "Spam Filters": {Score: 18, Status: "Pass"}, - "Blacklists": {Score: 20, Status: "Pass"}, - "Content Quality": {Score: 15, Status: "Warn"}, - "Email Structure": {Score: 7, Status: "Warn"}, - }, - }, - }, - expectEmpty: false, - expectString: "8.5/10", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - summary := gen.GetScoreSummaryText(tt.results) - if tt.expectEmpty { - if summary != "" { - t.Errorf("Expected empty summary, got %q", summary) - } - } else { - if summary == "" { - t.Error("Expected non-empty summary") - } - if tt.expectString != "" && !strings.Contains(summary, tt.expectString) { - t.Errorf("Summary should contain %q, got %q", tt.expectString, summary) - } - } - }) - } -} - -func TestReportCategories(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) - testID := uuid.New() - - email := createComprehensiveTestEmail() - results := gen.AnalyzeEmail(email) - report := gen.GenerateReport(testID, results) - - // Verify all check categories are present - categories := make(map[api.CheckCategory]bool) - for _, check := range report.Checks { - categories[check.Category] = true - } - - expectedCategories := []api.CheckCategory{ - api.Authentication, - api.Dns, - api.Headers, - } - - for _, cat := range expectedCategories { - if !categories[cat] { - t.Errorf("Expected category %s not found in checks", cat) - } - } -} - // Helper functions func createTestEmail() *EmailMessage { @@ -484,21 +223,3 @@ func createTestEmailWithSpamAssassin() *EmailMessage { email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} return email } - -func createComprehensiveTestEmail() *EmailMessage { - email := createTestEmailWithSpamAssassin() - - // Add authentication headers - email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{ - "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass", - } - - // Add HTML content - email.Parts = append(email.Parts, MessagePart{ - ContentType: "text/html", - Content: "

    Test

    Link", - IsHTML: true, - }) - - return email -} diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 6db6e0c..3e33e78 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -45,11 +45,6 @@ func ScoreToGrade(score int) string { } } -// ScoreToCheckGrade converts a percentage score to an api.CheckGrade -func ScoreToCheckGrade(score int) api.CheckGrade { - return api.CheckGrade(ScoreToGrade(score)) -} - // ScoreToReportGrade converts a percentage score to an api.ReportGrade func ScoreToReportGrade(score int) api.ReportGrade { return api.ReportGrade(ScoreToGrade(score)) @@ -62,3 +57,29 @@ type DeliverabilityScorer struct{} func NewDeliverabilityScorer() *DeliverabilityScorer { return &DeliverabilityScorer{} } + +// CalculateSpamScore calculates spam score from SpamAssassin results +// Returns a score from 0-100 where higher is better +func (s *DeliverabilityScorer) CalculateSpamScore(result *SpamAssassinResult) int { + if result == nil { + return 100 // No spam scan results, assume good + } + + // SpamAssassin score typically ranges from -10 to +20 + // Score < 0 is very likely ham (good) + // Score 0-5 is threshold range (configurable, usually 5.0) + // Score > 5 is likely spam + + score := result.Score + + // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) + if score <= 0 { + return 100 // Perfect score for ham + } else if score >= result.RequiredScore { + return 0 // Failed spam test + } else { + // Linear scale between 0 and required threshold + percentage := (score / result.RequiredScore) * 100 + return int(100 - percentage) + } +} diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go index e464432..2cb19c3 100644 --- a/pkg/analyzer/scoring_test.go +++ b/pkg/analyzer/scoring_test.go @@ -21,254 +21,4 @@ package analyzer -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/api" -) - -func TestNewDeliverabilityScorer(t *testing.T) { - scorer := NewDeliverabilityScorer() - if scorer == nil { - t.Fatal("Expected scorer, got nil") - } -} - -func TestIsValidMessageID(t *testing.T) { - tests := []struct { - name string - messageID string - expected bool - }{ - { - name: "Valid Message-ID", - messageID: "", - expected: true, - }, - { - name: "Valid with UUID", - messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>", - expected: true, - }, - { - name: "Missing angle brackets", - messageID: "abc123@example.com", - expected: false, - }, - { - name: "Missing @ symbol", - messageID: "", - expected: false, - }, - { - name: "Multiple @ symbols", - messageID: "", - expected: false, - }, - { - name: "Empty local part", - messageID: "<@example.com>", - expected: false, - }, - { - name: "Empty domain part", - messageID: "", - expected: false, - }, - { - name: "Empty", - messageID: "", - expected: false, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.isValidMessageID(tt.messageID) - if result != tt.expected { - t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) - } - }) - } -} - -func TestCalculateScore(t *testing.T) { - tests := []struct { - name string - authResults *api.AuthenticationResults - spamResult *SpamAssassinResult - rblResults *RBLResults - contentResults *ContentResults - email *EmailMessage - minScore int - maxScore int - expectedGrade string - }{ - { - name: "Perfect email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: &api.AuthResult{Result: api.AuthResultResultPass}, - }, - spamResult: &SpamAssassinResult{ - Score: -1.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - Images: []ImageCheck{{HasAlt: true}}, - HasUnsubscribe: true, - TextPlainRatio: 0.8, - ImageTextRatio: 3.0, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 90.0, - maxScore: 100.0, - expectedGrade: "A+", - }, - { - name: "Poor email - auth issues", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultFail}, - Dkim: &[]api.AuthResult{}, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 8.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - { - IP: "192.0.2.1", - RBL: "zen.spamhaus.org", - Listed: true, - }, - }, - ListedCount: 1, - }, - contentResults: &ContentResults{ - HTMLValid: false, - Links: []LinkCheck{{Valid: true, Status: 404}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - minScore: 0.0, - maxScore: 50.0, - expectedGrade: "C", - }, - { - name: "Average email", - authResults: &api.AuthenticationResults{ - Spf: &api.AuthResult{Result: api.AuthResultResultPass}, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, - }, - Dmarc: nil, - }, - spamResult: &SpamAssassinResult{ - Score: 4.0, - RequiredScore: 5.0, - }, - rblResults: &RBLResults{ - Checks: []RBLCheck{ - {IP: "192.0.2.1", Listed: false}, - }, - }, - contentResults: &ContentResults{ - HTMLValid: true, - Links: []LinkCheck{{Valid: true, Status: 200}}, - HasUnsubscribe: false, - }, - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 60.0, - maxScore: 90.0, - expectedGrade: "A", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.CalculateScore( - tt.authResults, - tt.spamResult, - tt.rblResults, - tt.contentResults, - tt.email, - ) - - if result == nil { - t.Fatal("Expected result, got nil") - } - - // Check overall score - if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore { - t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore) - } - - // Check rating - if result.Grade != api.ReportGrade(tt.expectedGrade) { - t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade) - } - - // Verify score is within bounds - if result.OverallScore < 0.0 || result.OverallScore > 100.0 { - t.Errorf("OverallScore %v is out of bounds [0.0, 100.0]", result.OverallScore) - } - - // Verify category breakdown exists - if len(result.CategoryBreakdown) != 5 { - t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown)) - } - - // Verify recommendations exist - if len(result.Recommendations) == 0 && result.Grade != "A+" { - t.Error("Expected recommendations for non-excellent rating") - } - - // Verify category scores add up to overall score - totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - if totalCategoryScore != result.OverallScore { - t.Errorf("Category scores sum (%d) doesn't match overall score (%d)", - totalCategoryScore, result.OverallScore) - } - }) - } -} +import () diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index a3f175f..9675dbc 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -22,13 +22,10 @@ package analyzer import ( - "fmt" "math" "regexp" "strconv" "strings" - - "git.happydns.org/happyDeliver/internal/api" ) // SpamAssassinAnalyzer analyzes SpamAssassin results from email headers @@ -198,131 +195,3 @@ func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) // Linear scaling based on how negative/low the score is return 100 - int(math.Round(25*score/required)) } - -// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis -func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check { - var checks []api.Check - - if result == nil { - checks = append(checks, api.Check{ - Category: api.Spam, - Name: "SpamAssassin Analysis", - Status: api.CheckStatusWarn, - Score: 0.0, - Grade: ScoreToCheckGrade(0.0), - Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), - }) - return checks - } - - // Main spam score check - mainCheck := a.generateMainSpamCheck(result) - checks = append(checks, mainCheck) - - // Add checks for significant spam tests (score > 1.0 or < -1.0) - for _, test := range result.Tests { - if detail, ok := result.TestDetails[test]; ok { - if detail.Score > 1.0 || detail.Score < -1.0 { - check := a.generateTestCheck(detail) - checks = append(checks, check) - } - } - } - - return checks -} - -// generateMainSpamCheck creates the main spam score check -func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check { - check := api.Check{ - Category: api.Spam, - Name: "SpamAssassin Score", - } - - score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5.0 - } - - check.Score = a.GetSpamAssassinScore(result) - check.Grade = ScoreToCheckGrade(check.Score) - - // Determine status and message based on score - if score <= 0 { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") - } else if score < required { - check.Status = api.CheckStatusPass - check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your email passes spam filters") - } else if score < required*1.5 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") - } else if score < required*2 { - check.Status = api.CheckStatusWarn - check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") - } else { - check.Status = api.CheckStatusFail - check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") - } - - // Add details - if len(result.Tests) > 0 { - details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", ")) - if len(result.Tests) > 5 { - details += fmt.Sprintf(" and %d more", len(result.Tests)-5) - } - check.Details = &details - } - - return check -} - -// generateTestCheck creates a check for a specific spam test -func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check { - check := api.Check{ - Category: api.Spam, - Name: fmt.Sprintf("Spam Test: %s", detail.Name), - } - - if detail.Score > 0 { - // Negative indicator (increases spam score) - if detail.Score > 2.0 { - check.Status = api.CheckStatusFail - check.Severity = api.PtrTo(api.CheckSeverityHigh) - } else { - check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.CheckSeverityMedium) - } - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0) - check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) - advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) - check.Advice = &advice - } else { - // Positive indicator (decreases spam score) - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Grade = ScoreToCheckGrade((1.0 / 20.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) - advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) - check.Advice = &advice - } - - check.Details = &detail.Description - - return check -} diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index 54b9c0c..2ed2890 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -26,8 +26,6 @@ import ( "net/mail" "strings" "testing" - - "git.happydns.org/happyDeliver/internal/api" ) func TestParseSpamStatus(t *testing.T) { @@ -298,86 +296,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) { } } -func TestGenerateSpamAssassinChecks(t *testing.T) { - tests := []struct { - name string - result *SpamAssassinResult - expectedStatus api.CheckStatus - minChecks int - }{ - { - name: "Nil result", - result: nil, - expectedStatus: api.CheckStatusWarn, - minChecks: 1, - }, - { - name: "Clean email", - result: &SpamAssassinResult{ - IsSpam: false, - Score: -0.5, - RequiredScore: 5.0, - Tests: []string{"ALL_TRUSTED"}, - TestDetails: map[string]SpamTestDetail{ - "ALL_TRUSTED": { - Name: "ALL_TRUSTED", - Score: -1.5, - Description: "All mail servers are trusted", - }, - }, - }, - expectedStatus: api.CheckStatusPass, - minChecks: 2, // Main check + one test detail - }, - { - name: "Spam email", - result: &SpamAssassinResult{ - IsSpam: true, - Score: 15.0, - RequiredScore: 5.0, - Tests: []string{"BAYES_99", "SPOOFED_SENDER"}, - TestDetails: map[string]SpamTestDetail{ - "BAYES_99": { - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - "SPOOFED_SENDER": { - Name: "SPOOFED_SENDER", - Score: 3.5, - Description: "From address doesn't match envelope sender", - }, - }, - }, - expectedStatus: api.CheckStatusFail, - minChecks: 3, // Main check + two significant tests - }, - } - - analyzer := NewSpamAssassinAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := analyzer.GenerateSpamAssassinChecks(tt.result) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Check main check (first one) - if len(checks) > 0 { - mainCheck := checks[0] - if mainCheck.Status != tt.expectedStatus { - t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus) - } - if mainCheck.Category != api.Spam { - t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) - } - } - }) - } -} - func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { analyzer := NewSpamAssassinAnalyzer() email := &EmailMessage{ @@ -391,98 +309,6 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { } } -func TestGenerateMainSpamCheck(t *testing.T) { - analyzer := NewSpamAssassinAnalyzer() - - tests := []struct { - name string - score float64 - required float64 - expectedStatus api.CheckStatus - }{ - {"Excellent", -1.0, 5.0, api.CheckStatusPass}, - {"Good", 2.0, 5.0, api.CheckStatusPass}, - {"Borderline", 6.0, 5.0, api.CheckStatusWarn}, - {"High", 8.0, 5.0, api.CheckStatusWarn}, - {"Very High", 15.0, 5.0, api.CheckStatusFail}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - Score: tt.score, - RequiredScore: tt.required, - } - - check := analyzer.generateMainSpamCheck(result) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Message, "spam score") { - t.Error("Message should contain 'spam score'") - } - }) - } -} - -func TestGenerateTestCheck(t *testing.T) { - analyzer := NewSpamAssassinAnalyzer() - - tests := []struct { - name string - detail SpamTestDetail - expectedStatus api.CheckStatus - }{ - { - name: "High penalty test", - detail: SpamTestDetail{ - Name: "BAYES_99", - Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", - }, - expectedStatus: api.CheckStatusFail, - }, - { - name: "Medium penalty test", - detail: SpamTestDetail{ - Name: "HTML_MESSAGE", - Score: 1.5, - Description: "Contains HTML", - }, - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Positive test", - detail: SpamTestDetail{ - Name: "ALL_TRUSTED", - Score: -2.0, - Description: "All mail servers are trusted", - }, - expectedStatus: api.CheckStatusPass, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := analyzer.generateTestCheck(tt.detail) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Spam { - t.Errorf("Category = %v, want %v", check.Category, api.Spam) - } - if !strings.Contains(check.Name, tt.detail.Name) { - t.Errorf("Check name should contain test name %s", tt.detail.Name) - } - }) - } -} - const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID, DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED, @@ -623,34 +449,6 @@ func TestAnalyzeRealEmailExample(t *testing.T) { if score != 100 { t.Errorf("GetSpamAssassinScore() = %v, want 100 (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 != 100 { - t.Errorf("Main check score = %v, want 100", 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: %d, status: %s)", - i+1, check.Name, check.Message, check.Score, check.Status) - } } // Helper function to compare string slices diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte new file mode 100644 index 0000000..ec043cf --- /dev/null +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -0,0 +1,210 @@ + + +
    +
    +

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

    +
    +
    +
    + +
    +
    + {#if authentication.spf} + +
    + SPF + + {authentication.spf.result} + + {#if authentication.spf.domain} +
    + Domain: + {authentication.spf.domain} +
    + {/if} + {#if authentication.spf.details} +
    {authentication.spf.details}
    + {/if} +
    + {:else} + +
    + SPF + + {getAuthResultText('missing')} + +
    SPF record is required for proper email authentication
    +
    + {/if} +
    +
    + + +
    +
    + {#if authentication.dkim && authentication.dkim.length > 0} + +
    + DKIM + + {authentication.dkim[0].result} + + {#if authentication.dkim[0].domain} +
    {authentication.dkim[0].domain}
    + {/if} + {#if authentication.dkim[0].selector} +
    Selector: {authentication.dkim[0].selector}
    + {/if} + {#if authentication.dkim.details} +
    {authentication.dkim.details}
    + {/if} +
    + {:else} + +
    + DKIM + + {getAuthResultText('missing')} + +
    DKIM signature is required for proper email authentication
    +
    + {/if} +
    +
    + + +
    +
    + {#if authentication.dmarc} + +
    + DMARC + + {authentication.dmarc.result} + + {#if authentication.dmarc.details} +
    {authentication.dmarc.details}
    + {/if} +
    + {:else} + +
    + DMARC + + {getAuthResultText('missing')} + +
    DMARC policy is required for proper email authentication
    +
    + {/if} +
    +
    + + +
    +
    + {#if authentication.bimi} + +
    + BIMI + + {authentication.bimi.result} + + {#if authentication.bimi.details} +
    {authentication.bimi.details}
    + {/if} +
    + {:else} + +
    + BIMI + + Optional + +
    Brand Indicators for Message Identification (optional enhancement)
    +
    + {/if} +
    +
    + + + {#if authentication.arc} +
    +
    + +
    + ARC + + {authentication.arc.result} + + {#if authentication.arc.chain_length} +
    Chain length: {authentication.arc.chain_length}
    + {/if} + {#if authentication.arc.details} +
    {authentication.arc.details}
    + {/if} +
    +
    +
    + {/if} +
    +
    +
    diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte new file mode 100644 index 0000000..4794599 --- /dev/null +++ b/web/src/lib/components/BlacklistCard.svelte @@ -0,0 +1,60 @@ + + +
    +
    +

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

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

    {check.message}

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

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

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

    + + DNS Records +

    +
    +
    +
    + + + + + + + + + + + {#each dnsRecords as record} + + + + + + + {/each} + +
    DomainTypeStatusValue
    {record.domain}{record.record_type} + + {record.status} + + {record.value || '-'}
    +
    +
    +
    diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte new file mode 100644 index 0000000..6b03966 --- /dev/null +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -0,0 +1,169 @@ + + +
    +
    +

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

    +
    +
    + {#if headerAnalysis.issues && headerAnalysis.issues.length > 0} +
    +
    Issues
    + {#each headerAnalysis.issues as issue} +
    +
    +
    + {issue.header} +
    {issue.message}
    + {#if issue.advice} +
    + + {issue.advice} +
    + {/if} +
    + {issue.severity} +
    +
    + {/each} +
    + {/if} + + {#if headerAnalysis.domain_alignment} +
    +
    Domain Alignment
    +
    +
    +
    +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    +
    +
    + Return-Path Domain +
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    +
    +
    + Aligned +
    + + {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} +
    +
    +
    + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
    + {#each headerAnalysis.domain_alignment.issues as issue} +
    + + {issue} +
    + {/each} +
    + {/if} +
    +
    +
    + {/if} + + {#if headerAnalysis.headers && Object.keys(headerAnalysis.headers).length > 0} +
    +
    Headers
    +
    + + + + + + + + + + + {#each Object.entries(headerAnalysis.headers) as [name, check]} + + + + + + + {/each} + +
    HeaderPresentValidValue
    + {name} + {#if check.importance} + + {check.importance} + + {/if} + + + + {#if check.valid !== undefined} + + {:else} + - + {/if} + + {check.value || '-'} + {#if check.issues && check.issues.length > 0} + {#each check.issues as issue} +
    + + {issue} +
    + {/each} + {/if} +
    +
    +
    + {/if} + + {#if headerAnalysis.received_chain && headerAnalysis.received_chain.length > 0} +
    +
    Email Path (Received Chain)
    +
    + {#each headerAnalysis.received_chain as hop, i} +
    +
    +
    + {i + 1} + {hop.from || 'Unknown'} → {hop.by || 'Unknown'} +
    + {hop.timestamp || '-'} +
    + {#if hop.with || hop.id} +

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

    + {/if} +
    + {/each} +
    +
    + {/if} +
    +
    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 0b74a38..fb0912f 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -50,19 +50,6 @@ Authentication
    -
    -
    - = 100} - class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50} - class:text-danger={summary.spam_score < 50} - > - {summary.spam_score}% - - Spam Score -
    -
    Blacklists
    -
    -
    - = 100} - class:text-warning={summary.content_score < 100 && - summary.content_score >= 50} - class:text-danger={summary.content_score < 50} - > - {summary.content_score}% - - Content -
    -
    Headers
    +
    +
    + = 100} + class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50} + class:text-danger={summary.spam_score < 50} + > + {summary.spam_score}% + + Spam Score +
    +
    +
    +
    + = 100} + class:text-warning={summary.content_score < 100 && + summary.content_score >= 50} + class:text-danger={summary.content_score < 50} + > + {summary.content_score}% + + Content +
    +
    {/if}
    diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index 8da4188..a5b56ae 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -2,7 +2,11 @@ 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"; +export { default as AuthenticationCard } from "./AuthenticationCard.svelte"; +export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; +export { default as BlacklistCard } from "./BlacklistCard.svelte"; +export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; +export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index fd36ce7..0c52dd0 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -3,7 +3,16 @@ import { page } from "$app/state"; import { getTest, getReport, reanalyzeReport } from "$lib/api"; import type { Test, Report } from "$lib/api/types.gen"; - import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components"; + import { + ScoreCard, + SpamAssassinCard, + PendingState, + AuthenticationCard, + DnsRecordsCard, + BlacklistCard, + ContentAnalysisCard, + HeaderAnalysisCard + } from "$lib/components"; let testId = $derived(page.params.test); let test = $state(null); @@ -15,20 +24,6 @@ let nextfetch = $state(23); let nbfetch = $state(0); - // Group checks by category - let groupedChecks = $derived(() => { - if (!report) return { }; - - const groups: Record = { }; - for (const check of report.checks) { - if (!groups[check.category]) { - groups[check.category] = []; - } - groups[check.category].push(check); - } - return groups; - }); - async function fetchTest() { if (nbfetch > 0) { nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5)); @@ -86,29 +81,6 @@ stopPolling(); }); - function getCategoryIcon(category: string): string { - switch (category) { - case "authentication": - return "bi-shield-check"; - case "dns": - return "bi-diagram-3"; - case "content": - return "bi-file-text"; - case "blacklist": - return "bi-shield-exclamation"; - case "headers": - return "bi-list-ul"; - case "spam": - return "bi-filter"; - default: - return "bi-question-circle"; - } - } - - function getCategoryScore(checks: typeof report.checks): number { - return Math.round(checks.reduce((sum, check) => sum + check.score, 0) / checks.filter((c) => c.status != "info").length); - } - function getScoreColorClass(percentage: number): string { if (percentage >= 80) return "text-success"; if (percentage >= 50) return "text-warning"; @@ -166,45 +138,78 @@
    -
    +
    - -
    -
    -

    Detailed Checks

    - {#each Object.entries(groupedChecks()) as [category, checks]} - {@const categoryScore = getCategoryScore(checks)} -
    -

    - - - {category} - - - {categoryScore}% - -

    - {#each checks as check} - - {/each} -
    - {/each} + + {#if report.dns_records && report.dns_records.length > 0} +
    +
    + +
    -
    + {/if} + + + {#if report.authentication} +
    +
    + +
    +
    + {/if} + + + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
    +
    + +
    +
    + {/if} + + + {#if report.header_analysis} + + {/if} {#if report.spamassassin} -
    +
    {/if} + + {#if report.content_analysis} +
    +
    + +
    +
    + {/if} +
    From ec1ab7886e1343ee22c8abe867a1e5436924c41a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 17:00:15 +0700 Subject: [PATCH 053/256] Rework DNS results --- api/openapi.yaml | 169 +++++++++++-- pkg/analyzer/dns.go | 193 ++++++--------- pkg/analyzer/report.go | 119 +-------- web/src/lib/components/DnsRecordsCard.svelte | 242 ++++++++++++++++--- web/src/routes/test/[test]/+page.svelte | 4 +- 5 files changed, 441 insertions(+), 286 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index c78c71b..0432da1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -267,10 +267,8 @@ components: $ref: '#/components/schemas/AuthenticationResults' spamassassin: $ref: '#/components/schemas/SpamAssassinResult' - dns_records: - type: array - items: - $ref: '#/components/schemas/DNSRecord' + dns_results: + $ref: '#/components/schemas/DNSResults' blacklists: type: object additionalProperties: @@ -694,31 +692,168 @@ components: type: string description: Full SpamAssassin report - DNSRecord: + DNSResults: type: object required: - domain - - record_type - - status properties: domain: type: string description: Domain name example: "example.com" - record_type: + mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the domain + spf_record: + $ref: '#/components/schemas/SPFRecord' + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + errors: + type: array + items: + type: string + description: DNS lookup errors + + MXRecord: + type: object + required: + - host + - priority + - valid + properties: + host: type: string - enum: [MX, SPF, DKIM, DMARC, BIMI] - description: DNS record type - example: "SPF" - status: + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: type: string - enum: [found, missing, invalid] - description: Record status - example: "found" - value: + description: Error message if validation failed + example: "Failed to lookup MX records" + + SPFRecord: + type: object + required: + - valid + properties: + record: type: string - description: Record value + description: SPF record content example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + + DKIMRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" BlacklistCheck: type: object diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 0f7c111..27566cf 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -51,79 +51,25 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { } } -// DNSResults represents DNS validation results for an email -type DNSResults struct { - Domain string - MXRecords []MXRecord - SPFRecord *SPFRecord - DKIMRecords []DKIMRecord - DMARCRecord *DMARCRecord - BIMIRecord *BIMIRecord - Errors []string -} - -// MXRecord represents an MX record -type MXRecord struct { - Host string - Priority uint16 - Valid bool - Error string -} - -// SPFRecord represents an SPF record -type SPFRecord struct { - Record string - Valid bool - Error string -} - -// DKIMRecord represents a DKIM record -type DKIMRecord struct { - Selector string - Domain string - Record string - Valid bool - Error string -} - -// DMARCRecord represents a DMARC record -type DMARCRecord struct { - Record string - Policy string // none, quarantine, reject - Valid bool - Error string -} - -// BIMIRecord represents a BIMI record -type BIMIRecord struct { - Selector string - Domain string - Record string - LogoURL string // URL to the brand logo (SVG) - VMCURL string // URL to Verified Mark Certificate (optional) - Valid bool - Error string -} - // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults { // Extract domain from From address domain := d.extractDomain(email) if domain == "" { - return &DNSResults{ - Errors: []string{"Unable to extract domain from email"}, + return &api.DNSResults{ + Errors: &[]string{"Unable to extract domain from email"}, } } - results := &DNSResults{ + results := &api.DNSResults{ Domain: domain, } // Check MX records - results.MXRecords = d.checkMXRecords(domain) + results.MxRecords = d.checkMXRecords(domain) // Check SPF record - results.SPFRecord = d.checkSPFRecord(domain) + results.SpfRecord = d.checkSPFRecord(domain) // Check DKIM records (from authentication results) if authResults != nil && authResults.Dkim != nil { @@ -131,17 +77,20 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic if dkim.Domain != nil && dkim.Selector != nil { dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) if dkimRecord != nil { - results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) + if results.DkimRecords == nil { + results.DkimRecords = new([]api.DKIMRecord) + } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } } } // Check DMARC record - results.DMARCRecord = d.checkDMARCRecord(domain) + results.DmarcRecord = d.checkDMARCRecord(domain) // Check BIMI record (using default selector) - results.BIMIRecord = d.checkBIMIRecord(domain, "default") + results.BimiRecord = d.checkBIMIRecord(domain, "default") return results } @@ -158,51 +107,51 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { } // checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() mxRecords, err := d.resolver.LookupMX(ctx, domain) if err != nil { - return []MXRecord{ + return &[]api.MXRecord{ { Valid: false, - Error: fmt.Sprintf("Failed to lookup MX records: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), }, } } if len(mxRecords) == 0 { - return []MXRecord{ + return &[]api.MXRecord{ { Valid: false, - Error: "No MX records found", + Error: api.PtrTo("No MX records found"), }, } } - var results []MXRecord + var results []api.MXRecord for _, mx := range mxRecords { - results = append(results, MXRecord{ + results = append(results, api.MXRecord{ Host: mx.Host, Priority: mx.Pref, Valid: true, }) } - return results + return &results } // checkSPFRecord looks up and validates SPF record for a domain -func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { +func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { - return &SPFRecord{ + return &api.SPFRecord{ Valid: false, - Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), } } @@ -217,31 +166,31 @@ func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { } if spfCount == 0 { - return &SPFRecord{ + return &api.SPFRecord{ Valid: false, - Error: "No SPF record found", + Error: api.PtrTo("No SPF record found"), } } if spfCount > 1 { - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: false, - Error: "Multiple SPF records found (RFC violation)", + Error: api.PtrTo("Multiple SPF records found (RFC violation)"), } } // Basic validation if !d.validateSPF(spfRecord) { - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: false, - Error: "SPF record appears malformed", + Error: api.PtrTo("SPF record appears malformed"), } } - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: true, } } @@ -267,8 +216,8 @@ func (d *DNSAnalyzer) validateSPF(record string) bool { return hasValidEnding } -// checkDKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { +// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) @@ -277,20 +226,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), } } if len(txtRecords) == 0 { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: "No DKIM record found", + Error: api.PtrTo("No DKIM record found"), } } @@ -299,19 +248,19 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { // Basic validation - should contain "v=DKIM1" and "p=" (public key) if !d.validateDKIM(dkimRecord) { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, - Record: dkimRecord, + Record: api.PtrTo(dkimRecord), Valid: false, - Error: "DKIM record appears malformed", + Error: api.PtrTo("DKIM record appears malformed"), } } - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, - Record: dkimRecord, + Record: &dkimRecord, Valid: true, } } @@ -332,8 +281,8 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool { return true } -// checkDMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { +// checkapi.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { // DMARC records are at: _dmarc.domain dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) @@ -342,9 +291,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) if err != nil { - return &DMARCRecord{ + return &api.DMARCRecord{ Valid: false, - Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } @@ -358,9 +307,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { } if dmarcRecord == "" { - return &DMARCRecord{ + return &api.DMARCRecord{ Valid: false, - Error: "No DMARC record found", + Error: api.PtrTo("No DMARC record found"), } } @@ -369,17 +318,17 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { // Basic validation if !d.validateDMARC(dmarcRecord) { - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), Valid: false, - Error: "DMARC record appears malformed", + Error: api.PtrTo("DMARC record appears malformed"), } } - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), Valid: true, } } @@ -411,7 +360,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { } // checkBIMIRecord looks up and validates BIMI record for a domain and selector -func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { // BIMI records are at: selector._bimi.domain bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) @@ -420,20 +369,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) if err != nil { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), } } if len(txtRecords) == 0 { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: "No BIMI record found", + Error: api.PtrTo("No BIMI record found"), } } @@ -446,23 +395,23 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) if !d.validateBIMI(bimiRecord) { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, Valid: false, - Error: "BIMI record appears malformed", + Error: api.PtrTo("BIMI record appears malformed"), } } - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, Valid: true, } } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 135dee1..853b393 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -62,7 +62,7 @@ type AnalysisResults struct { Email *EmailMessage Authentication *api.AuthenticationResults Content *ContentResults - DNS *DNSResults + DNS *api.DNSResults Headers *api.HeaderAnalysis RBL *RBLResults SpamAssassin *SpamAssassinResult @@ -141,10 +141,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu // Add DNS records if results.DNS != nil { - dnsRecords := r.buildDNSRecords(results.DNS) - if len(dnsRecords) > 0 { - report.DnsRecords = &dnsRecords - } + report.DnsResults = results.DNS } // Add headers results @@ -204,118 +201,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu return report } -// buildDNSRecords converts DNS analysis results to API DNS records -func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { - records := []api.DNSRecord{} - - if dns == nil { - return records - } - - // MX records - if len(dns.MXRecords) > 0 { - for _, mx := range dns.MXRecords { - status := api.Found - if !mx.Valid { - if mx.Error != "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.MX, - Status: status, - } - - if mx.Host != "" { - value := mx.Host - record.Value = &value - } - - records = append(records, record) - } - } - - // SPF record - if dns.SPFRecord != nil { - status := api.Found - if !dns.SPFRecord.Valid { - if dns.SPFRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.SPF, - Status: status, - } - - if dns.SPFRecord.Record != "" { - record.Value = &dns.SPFRecord.Record - } - - records = append(records, record) - } - - // DKIM records - for _, dkim := range dns.DKIMRecords { - status := api.Found - if !dkim.Valid { - if dkim.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dkim.Domain, - RecordType: api.DKIM, - Status: status, - } - - if dkim.Record != "" { - // Include selector in value for clarity - value := dkim.Record - record.Value = &value - } - - records = append(records, record) - } - - // DMARC record - if dns.DMARCRecord != nil { - status := api.Found - if !dns.DMARCRecord.Valid { - if dns.DMARCRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.DMARC, - Status: status, - } - - if dns.DMARCRecord.Record != "" { - record.Value = &dns.DMARCRecord.Record - } - - records = append(records, record) - } - - return records -} - // GenerateRawEmail returns the raw email message as a string func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { if email == nil { diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 79272f5..7c624ed 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,11 +1,11 @@
    @@ -16,31 +16,217 @@
    -
    - - - - - - - - - - - {#each dnsRecords as record} - - - - - - + {#if !dnsResults} +

    No DNS results available

    + {:else} +
    + Domain: {dnsResults.domain} +
    + + {#if dnsResults.errors && dnsResults.errors.length > 0} +
    + Errors: +
      + {#each dnsResults.errors as error} +
    • {error}
    • + {/each} +
    +
    + {/if} + + + {#if dnsResults.mx_records && dnsResults.mx_records.length > 0} +
    +
    + MX Mail Exchange Records +
    +
    +
    DomainTypeStatusValue
    {record.domain}{record.record_type} - - {record.status} - - {record.value || '-'}
    + + + + + + + + + {#each dnsResults.mx_records as mx} + + + + + + {/each} + +
    PriorityHostStatus
    {mx.priority}{mx.host} + {#if mx.valid} + Valid + {:else} + Invalid + {#if mx.error} +
    {mx.error} + {/if} + {/if} +
    +
    +
    + {/if} + + + {#if dnsResults.spf_record} +
    +
    + SPF Sender Policy Framework +
    +
    +
    +
    + Status: + {#if dnsResults.spf_record.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if dnsResults.spf_record.record} +
    + Record:
    + {dnsResults.spf_record.record} +
    + {/if} + {#if dnsResults.spf_record.error} +
    + Error: {dnsResults.spf_record.error} +
    + {/if} +
    +
    +
    + {/if} + + + {#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0} +
    +
    + DKIM DomainKeys Identified Mail +
    + {#each dnsResults.dkim_records as dkim} +
    +
    +
    + Selector: {dkim.selector} + Domain: {dkim.domain} +
    +
    + Status: + {#if dkim.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if dkim.record} +
    + Record:
    + {dkim.record} +
    + {/if} + {#if dkim.error} +
    + Error: {dkim.error} +
    + {/if} +
    +
    {/each} - - -
    +
    + {/if} + + + {#if dnsResults.dmarc_record} +
    +
    + DMARC Domain-based Message Authentication +
    +
    +
    +
    + Status: + {#if dnsResults.dmarc_record.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if dnsResults.dmarc_record.policy} +
    + Policy: + + {dnsResults.dmarc_record.policy} + +
    + {/if} + {#if dnsResults.dmarc_record.record} +
    + Record:
    + {dnsResults.dmarc_record.record} +
    + {/if} + {#if dnsResults.dmarc_record.error} +
    + Error: {dnsResults.dmarc_record.error} +
    + {/if} +
    +
    +
    + {/if} + + + {#if dnsResults.bimi_record} +
    +
    + BIMI Brand Indicators for Message Identification +
    +
    +
    +
    + Selector: {dnsResults.bimi_record.selector} + Domain: {dnsResults.bimi_record.domain} +
    +
    + Status: + {#if dnsResults.bimi_record.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if dnsResults.bimi_record.logo_url} + + {/if} + {#if dnsResults.bimi_record.vmc_url} + + {/if} + {#if dnsResults.bimi_record.record} +
    + Record:
    + {dnsResults.bimi_record.record} +
    + {/if} + {#if dnsResults.bimi_record.error} +
    + Error: {dnsResults.bimi_record.error} +
    + {/if} +
    +
    +
    + {/if} + {/if}
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 0c52dd0..3af06f9 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -145,10 +145,10 @@
    - {#if report.dns_records && report.dns_records.length > 0} + {#if report.dns_results}
    - +
    {/if} From 0581e0cf6be1c32afe29b7b0dc6bbff2300fa3d3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Oct 2025 20:01:01 +0700 Subject: [PATCH 054/256] Use authentication_milter instead of opendkim and opendmarc --- Dockerfile | 98 ++++++++++++++++--- README.md | 5 +- .../authentication_milter.json | 69 +++++++++++++ docker/authentication_milter/mail-dmarc.ini | 58 +++++++++++ docker/entrypoint.sh | 19 ++-- docker/opendkim/opendkim.conf | 39 -------- docker/opendmarc/opendmarc.conf | 41 -------- docker/postfix/main.cf | 5 +- docker/supervisor/supervisord.conf | 23 ++--- 9 files changed, 226 insertions(+), 131 deletions(-) create mode 100644 docker/authentication_milter/authentication_milter.json create mode 100644 docker/authentication_milter/mail-dmarc.ini delete mode 100644 docker/opendkim/opendkim.conf delete mode 100644 docker/opendmarc/opendmarc.conf diff --git a/Dockerfile b/Dockerfile index 36d7d33..eee71bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,19 +31,88 @@ COPY --from=nodebuild /build/web/build/ ./web/build/ RUN go generate ./... && \ CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver -# Stage 3: Runtime image with Postfix and all filters +# Stage 3: Prepare perl +FROM alpine:3 AS pl + +RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ + apk add --no-cache \ + build-base \ + musl-obstack-dev \ + openssl \ + openssl-dev \ + perl-app-cpanminus \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@testing \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ + perl-dev \ + zlib-dev \ + && \ + ln -s /usr/bin/ld /bin/ld + +RUN cpanm --notest Mail::SPF && \ + cpanm --notest Mail::Milter::Authentication + +# Stage 4: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages -RUN apk add --no-cache \ +RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ + apk add --no-cache \ bash \ ca-certificates \ - opendkim \ - opendkim-utils \ - opendmarc \ + openssl \ + perl \ + perl-alien-libxml2 \ + perl-class-load-xs \ + perl-cpanel-json-xs \ + perl-crypt-openssl-rsa \ + perl-crypt-openssl-random \ + perl-crypt-openssl-verify \ + perl-crypt-openssl-x509 \ + perl-dbd-sqlite \ + perl-dbi \ + perl-email-address-xs \ + perl-json-xs \ + perl-list-moreutils \ + perl-moose \ + perl-net-idn-encode@testing \ + perl-net-ssleay \ + perl-netaddr-ip \ + perl-package-stash \ + perl-params-util \ + perl-params-validate \ + perl-proc-processtable \ + perl-sereal-decoder \ + perl-sereal-encoder \ + perl-socket6 \ + perl-sub-identify \ + perl-variable-magic \ + perl-xml-libxml \ postfix \ postfix-pcre \ - postfix-policyd-spf-perl \ spamassassin \ spamassassin-client \ supervisor \ @@ -51,9 +120,8 @@ RUN apk add --no-cache \ tzdata \ && rm -rf /var/cache/apk/* -# Get test-only version of postfix-policyd-spf-perl -ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl -RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl +# Copy Mail::Milter::Authentication and its dependancies +COPY --from=pl /usr/local/ /usr/local/ # Create happydeliver user and group RUN addgroup -g 1000 happydeliver && \ @@ -63,12 +131,11 @@ RUN addgroup -g 1000 happydeliver && \ RUN mkdir -p /etc/happydeliver \ /var/lib/happydeliver \ /var/log/happydeliver \ - /var/spool/postfix/opendkim \ - /var/spool/postfix/opendmarc \ - /etc/opendkim/keys \ + /var/cache/authentication_milter \ + /var/lib/authentication_milter \ + /var/spool/postfix/authentication_milter \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R opendkim:postfix /var/spool/postfix/opendkim \ - && chown -R opendmarc:postfix /var/spool/postfix/opendmarc + && chown -R mail:mail /var/spool/postfix/authentication_milter # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -76,8 +143,7 @@ RUN chmod +x /usr/local/bin/happyDeliver # Copy configuration files COPY docker/postfix/ /etc/postfix/ -COPY docker/opendkim/ /etc/opendkim/ -COPY docker/opendmarc/ /etc/opendmarc/ +COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/spamassassin/ /etc/mail/spamassassin/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh diff --git a/README.md b/README.md index a4ded59..fe03381 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,12 @@ An open-source email deliverability testing platform that analyzes test emails a ### With Docker (Recommended) -The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application. +The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application. #### What's included in the Docker container: - **Postfix MTA**: Receives emails on port 25 -- **OpenDKIM**: DKIM signature verification -- **OpenDMARC**: DMARC policy validation +- **authentication_milter**: Entreprise grade email authentication - **SpamAssassin**: Spam scoring and analysis - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json new file mode 100644 index 0000000..2f65d3b --- /dev/null +++ b/docker/authentication_milter/authentication_milter.json @@ -0,0 +1,69 @@ +{ + "logtoerr" : "1", + "error_log" : "", + "connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock", + "umask" : "0007", + "runas" : "mail", + "rungroup" : "mail", + "authserv_id" : "__HOSTNAME__", + + "connect_timeout" : 30, + "command_timeout" : 30, + "content_timeout" : 300, + "dns_timeout" : 10, + "dns_retry" : 2, + + "handlers" : { + + "Sanitize" : { + "hosts_to_remove" : [ + "__HOSTNAME__" + ] + }, + + "SPF" : { + "hide_none" : 0 + }, + + "DKIM" : { + "hide_none" : 0, + }, + + "XGoogleDKIM" : { + "hide_none" : 1, + }, + + "ARC" : { + "hide_none" : 0, + }, + + "DMARC" : { + "hide_none" : 0, + "detect_list_id" : "1" + }, + + "BIMI" : {}, + + "PTR" : {}, + + "SenderID" : { + "hide_none" : 1 + }, + + "IPRev" : {}, + + "Auth" : {}, + + "AlignedFrom" : {}, + + "LocalIP" : {}, + + "TrustedIP" : { + "trusted_ip_list" : [] + }, + + "!AddID" : {}, + + "ReturnOK" : {} + } +} diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini new file mode 100644 index 0000000..8097ac6 --- /dev/null +++ b/docker/authentication_milter/mail-dmarc.ini @@ -0,0 +1,58 @@ +; This is YOU. DMARC reports include information about the reports. Enter it here. +[organization] +domain = example.com +org_name = My Company Limited +email = admin@example.com +extra_contact_info = http://example.com + +; aggregate DMARC reports need to be stored somewhere. Any database +; with a DBI module (MySQL, SQLite, DBD, etc.) should work. +; SQLite and MySQL are tested. +; Default is sqlite. +[report_store] +backend = SQL +;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite +dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306 +user = authmilterusername +pass = authmiltpassword + +; backend can be perl or libopendmarc +[dmarc] +backend = perl + +[dns] +timeout = 5 +public_suffix_list = share/public_suffix_list + +[smtp] +; hostname is the external FQDN of this MTA +hostname = mx1.example.com +cc = dmarc.copy@example.com + +; list IP addresses to whitelist (bypass DMARC reject/quarantine) +; see sample whitelist in share/dmarc_whitelist +whitelist = /path/to/etc/dmarc_whitelist + +; By default, we attempt to email directly to the report recipient. +; Set these to relay via a SMTP smart host. +smarthost = mx2.example.com +smartuser = dmarccopyusername +smartpass = dmarccopypassword + +[imap] +server = mail.example.com +user = +pass = +; the imap folder where new dmarc messages will be found +folder = dmarc +; the folders to store processed reports (a=aggregate, f=forensic) +f_done = dmarc.forensic +a_done = dmarc.aggregate + +[http] +port = 8080 + +[https] +port = 8443 +ssl_crt = +ssl_key = \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 445602d..99744f6 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,28 +10,23 @@ HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" echo "Domain: $HAPPYDELIVER_DOMAIN" -# Create runtime directories -mkdir -p /var/run/opendkim /var/run/opendmarc -chown opendkim:postfix /var/run/opendkim -chown opendmarc:postfix /var/run/opendmarc - # Create socket directories -mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc -chown opendkim:postfix /var/spool/postfix/opendkim -chown opendmarc:postfix /var/spool/postfix/opendmarc -chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc +mkdir -p /var/spool/postfix/authentication_milter +chown mail:mail /var/spool/postfix/authentication_milter +chmod 750 /var/spool/postfix/authentication_milter # Create log directory -mkdir -p /var/log/happydeliver +mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter chown happydeliver:happydeliver /var/log/happydeliver +chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter # Replace placeholders in Postfix configuration echo "Configuring Postfix..." sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf -# Replace placeholders in OpenDMARC configuration -sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf +# Replace placeholders in configurations +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json # Initialize Postfix aliases if [ -f /etc/postfix/aliases ]; then diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf deleted file mode 100644 index 8fe2f8c..0000000 --- a/docker/opendkim/opendkim.conf +++ /dev/null @@ -1,39 +0,0 @@ -# OpenDKIM configuration for happyDeliver -# Verifies DKIM signatures on incoming emails - -# Log to syslog -Syslog yes -SyslogSuccess yes -LogWhy yes - -# Run as this user and group -UserID opendkim:mail - -UMask 002 - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendkim/opendkim.sock - -# Process ID file -PidFile /var/run/opendkim/opendkim.pid - -# Operating mode - verify only (not signing) -Mode v - -# Canonicalization methods -Canonicalization relaxed/simple - -# DNS timeout -DNSTimeout 5 - -# Add header for verification results -AlwaysAddARHeader yes - -# Accept unsigned mail -On-NoSignature accept - -# Always add Authentication-Results header -AlwaysAddARHeader yes - -# Maximum verification attempts -MaximumSignaturesToVerify 3 diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf deleted file mode 100644 index 882e11c..0000000 --- a/docker/opendmarc/opendmarc.conf +++ /dev/null @@ -1,41 +0,0 @@ -# OpenDMARC configuration for happyDeliver -# Verifies DMARC policies on incoming emails - -# Socket for Postfix communication -Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock - -# Process ID file -PidFile /var/run/opendmarc/opendmarc.pid - -# Run as this user and group -UserID opendmarc:mail - -UMask 002 - -# Syslog configuration -Syslog true -SyslogFacility mail - -# Ignore authentication results from other hosts -IgnoreAuthenticatedClients true - -# Accept mail even if DMARC fails (we're analyzing, not filtering) -RejectFailures false - -# Trust Authentication-Results headers from localhost only -TrustedAuthservIDs __HOSTNAME__ - -# Add DMARC results to Authentication-Results header -#AddAuthenticationResults true - -# DNS timeout -DNSTimeout 5 - -# History file (for reporting) -# HistoryFile /var/spool/opendmarc/opendmarc.dat - -# Ignore hosts file -# IgnoreHosts /etc/opendmarc/ignore.hosts - -# Public suffix list -# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 913eb57..e7d1fb0 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -28,14 +28,13 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking smtpd_recipient_restrictions = permit_mynetworks, - reject_unauth_destination, - check_policy_service unix:private/policy-spf + reject_unauth_destination # Logging debug_peer_level = 2 diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 1a0666e..4d4ff32 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -22,26 +22,15 @@ autostart=true autorestart=true priority=9 -# OpenDKIM service -[program:opendkim] -command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf +# Authentication Milter service +[program:authentication_milter] +command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid autostart=true autorestart=true priority=10 -stdout_logfile=/var/log/happydeliver/opendkim.log -stderr_logfile=/var/log/happydeliver/opendkim_error.log -user=opendkim -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 +stdout_logfile=/var/log/happydeliver/authentication_milter.log +stderr_logfile=/var/log/happydeliver/authentication_milter.log +user=mail group=mail # SpamAssassin daemon From eadc7ff8ca7cb7b16301d863fa5f02b6d2ef2fb0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 11:33:20 +0700 Subject: [PATCH 055/256] docker: Use spamass-milter --- Dockerfile | 13 +++++++++++-- .../authentication_milter.json | 6 ++++++ docker/postfix/main.cf | 4 ++-- docker/postfix/master.cf | 5 ----- docker/supervisor/supervisord.conf | 12 ++++++++++++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index eee71bd..6e099f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,12 +31,13 @@ COPY --from=nodebuild /build/web/build/ ./web/build/ RUN go generate ./... && \ CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver -# Stage 3: Prepare perl +# Stage 3: Prepare perl and spamass-milt FROM alpine:3 AS pl RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ apk add --no-cache \ build-base \ + libmilter-dev \ musl-obstack-dev \ openssl \ openssl-dev \ @@ -68,6 +69,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a perl-variable-magic \ perl-xml-libxml \ perl-dev \ + spamassassin-client \ zlib-dev \ && \ ln -s /usr/bin/ld /bin/ld @@ -75,6 +77,11 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a RUN cpanm --notest Mail::SPF && \ cpanm --notest Mail::Milter::Authentication +RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \ + tar xzf spamass-milter-0.4.0.tar.gz && \ + cd spamass-milter-0.4.0 && \ + ./configure && make install + # Stage 4: Runtime image with Postfix and all filters FROM alpine:3 @@ -83,6 +90,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/a apk add --no-cache \ bash \ ca-certificates \ + libmilter \ openssl \ perl \ perl-alien-libxml2 \ @@ -134,8 +142,9 @@ RUN mkdir -p /etc/happydeliver \ /var/cache/authentication_milter \ /var/lib/authentication_milter \ /var/spool/postfix/authentication_milter \ + /var/spool/postfix/spamassassin \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R mail:mail /var/spool/postfix/authentication_milter + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json index 2f65d3b..5db3bbc 100644 --- a/docker/authentication_milter/authentication_milter.json +++ b/docker/authentication_milter/authentication_milter.json @@ -18,6 +18,12 @@ "Sanitize" : { "hosts_to_remove" : [ "__HOSTNAME__" + ], + "extra_auth_results_types" : [ + "X-Spam-Status", + "X-Spam-Report", + "X-Spam-Level", + "X-Spam-Checker-Version" ] }, diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index e7d1fb0..fcdb75c 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -10,7 +10,7 @@ inet_interfaces = all inet_protocols = ipv4 # Recipient settings -mydestination = $myhostname, localhost.$mydomain, localhost +mydestination = localhost.$mydomain, localhost mynetworks = 127.0.0.0/8 [::1]/128 # Relay settings - accept mail for our test domain @@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 92976a4..9c2ac57 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -2,7 +2,6 @@ # SMTP service smtp inet n - n - - smtpd - -o content_filter=spamassassin # Pickup service pickup unix n - n 60 1 pickup @@ -74,10 +73,6 @@ scache unix - - n - 1 scache maildrop unix - n n - - pipe flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} -# SPF policy service -policy-spf unix - n n - 0 spawn - user=nobody argv=/usr/bin/postfix-policyd-spf-perl - # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 4d4ff32..c0c7002 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -43,6 +43,18 @@ stdout_logfile=/var/log/happydeliver/spamd.log stderr_logfile=/var/log/happydeliver/spamd_error.log user=root +# SpamAssassin milter +[program:spamass_milter] +command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m +autostart=true +autorestart=true +priority=7 +stdout_logfile=/var/log/happydeliver/spamass-milter.log +stderr_logfile=/var/log/happydeliver/spamass-milter_error.log +user=mail +group=mail +umask=007 + # Postfix service [program:postfix] command=/usr/sbin/postfix start-fg From 866cf2e5dbe7bbb86bfbdfaae6f7e9b484f25208 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 12:26:07 +0700 Subject: [PATCH 056/256] Refactor spam score --- pkg/analyzer/report.go | 4 +--- pkg/analyzer/scoring.go | 34 ---------------------------------- pkg/analyzer/scoring_test.go | 24 ------------------------ pkg/analyzer/spamassassin.go | 30 +++++++++++++++--------------- 4 files changed, 16 insertions(+), 76 deletions(-) delete mode 100644 pkg/analyzer/scoring_test.go diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 853b393..9cefcbd 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -37,7 +37,6 @@ type ReportGenerator struct { rblChecker *RBLChecker contentAnalyzer *ContentAnalyzer headerAnalyzer *HeaderAnalyzer - scorer *DeliverabilityScorer } // NewReportGenerator creates a new report generator @@ -53,7 +52,6 @@ func NewReportGenerator( rblChecker: NewRBLChecker(dnsTimeout, rbls), contentAnalyzer: NewContentAnalyzer(httpTimeout), headerAnalyzer: NewHeaderAnalyzer(), - scorer: NewDeliverabilityScorer(), } } @@ -119,7 +117,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu spamScore := 0 if results.SpamAssassin != nil { - spamScore = r.scorer.CalculateSpamScore(results.SpamAssassin) + spamScore = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) } report.Summary = &api.ScoreSummary{ diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 3e33e78..4686cc4 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -49,37 +49,3 @@ func ScoreToGrade(score int) string { func ScoreToReportGrade(score int) api.ReportGrade { return api.ReportGrade(ScoreToGrade(score)) } - -// DeliverabilityScorer aggregates all analysis results and computes overall score -type DeliverabilityScorer struct{} - -// NewDeliverabilityScorer creates a new deliverability scorer -func NewDeliverabilityScorer() *DeliverabilityScorer { - return &DeliverabilityScorer{} -} - -// CalculateSpamScore calculates spam score from SpamAssassin results -// Returns a score from 0-100 where higher is better -func (s *DeliverabilityScorer) CalculateSpamScore(result *SpamAssassinResult) int { - if result == nil { - return 100 // No spam scan results, assume good - } - - // SpamAssassin score typically ranges from -10 to +20 - // Score < 0 is very likely ham (good) - // Score 0-5 is threshold range (configurable, usually 5.0) - // Score > 5 is likely spam - - score := result.Score - - // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) - if score <= 0 { - return 100 // Perfect score for ham - } else if score >= result.RequiredScore { - return 0 // Failed spam test - } else { - // Linear scale between 0 and required threshold - percentage := (score / result.RequiredScore) * 100 - return int(100 - percentage) - } -} diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go deleted file mode 100644 index 2cb19c3..0000000 --- a/pkg/analyzer/scoring_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import () diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 9675dbc..7c0b555 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -172,26 +172,26 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability -func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) int { +// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *SpamAssassinResult) int { if result == nil { - return 0 + return 100 // No spam scan results, assume good } + // SpamAssassin score typically ranges from -10 to +20 + // Score < 0 is very likely ham (good) + // Score 0-5 is threshold range (configurable, usually 5.0) + // Score > 5 is likely spam + score := result.Score - required := result.RequiredScore - if required == 0 { - required = 5 // Default SpamAssassin threshold - } - // Calculate deliverability score + // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) if score <= 0 { - return 100 + return 100 // Perfect score for ham + } else if score >= result.RequiredScore { + return 0 // Failed spam test + } else { + // Linear scale between 0 and required threshold + return 100 - int(math.Round(score*100/result.RequiredScore)) } - if score <= required*4 { - return 0 - } - - // Linear scaling based on how negative/low the score is - return 100 - int(math.Round(25*score/required)) } From c51f8e5904e61877fa4a330e76142ac29cc67726 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 12:26:46 +0700 Subject: [PATCH 057/256] Improve authentication results --- pkg/analyzer/authentication.go | 9 +- pkg/analyzer/authentication_test.go | 707 +++++++++++++++++- .../lib/components/AuthenticationCard.svelte | 59 +- web/src/routes/test/[test]/+page.svelte | 1 + 4 files changed, 756 insertions(+), 20 deletions(-) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 310c8f6..14333bd 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -153,14 +153,7 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { } } - // 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 - } - } + result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) return result } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index e7f1e06..e7176db 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -22,6 +22,7 @@ package analyzer import ( + "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -264,7 +265,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 30, + expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30 }, { name: "SPF and DKIM only", @@ -276,7 +277,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 20, + expectedScore: 60, // SPF=30 + DKIM=30 }, { name: "SPF fail, DKIM pass", @@ -288,7 +289,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 10, + expectedScore: 30, // SPF=0 + DKIM=30 }, { name: "SPF softfail", @@ -305,7 +306,7 @@ func TestGetAuthenticationScore(t *testing.T) { expectedScore: 0, }, { - name: "BIMI doesn't affect score", + name: "BIMI adds to score", results: &api.AuthenticationResults{ Spf: &api.AuthResult{ Result: api.AuthResultResultPass, @@ -314,7 +315,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 10, // Only SPF counted, not BIMI + expectedScore: 40, // SPF (30) + BIMI (10) }, } @@ -367,6 +368,461 @@ func TestParseARCResult(t *testing.T) { } } +func TestParseAuthenticationResultsHeader(t *testing.T) { + tests := []struct { + name string + header string + expectedSPFResult *api.AuthResultResult + expectedSPFDomain *string + expectedDKIMCount int + expectedDKIMResult *api.AuthResultResult + expectedDMARCResult *api.AuthResultResult + expectedDMARCDomain *string + expectedBIMIResult *api.AuthResultResult + expectedARCResult *api.ARCResultResult + }{ + { + name: "Complete authentication results", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCDomain: api.PtrTo("example.com"), + }, + { + name: "SPF only", + header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("domain.com"), + expectedDKIMCount: 0, + expectedDMARCResult: nil, + }, + { + name: "DKIM only", + header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + }, + { + name: "Multiple DKIM signatures", + header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", + expectedSPFResult: nil, + expectedDKIMCount: 2, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: nil, + }, + { + name: "SPF fail with DKIM pass", + header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default", + expectedSPFResult: api.PtrTo(api.AuthResultResultFail), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: nil, + }, + { + name: "SPF softfail", + header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", + expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + expectedDMARCResult: nil, + }, + { + name: "DMARC fail", + header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), + expectedDMARCDomain: api.PtrTo("example.com"), + }, + { + name: "BIMI pass", + header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + }, + { + name: "ARC pass", + header: "mail.example.com; arc=pass", + expectedSPFResult: nil, + expectedDKIMCount: 0, + expectedARCResult: api.PtrTo(api.ARCResultResultPass), + }, + { + name: "All authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCDomain: api.PtrTo("example.com"), + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + expectedARCResult: api.PtrTo(api.ARCResultResultPass), + }, + { + name: "Empty header (authserv-id only)", + header: "mx.google.com", + expectedSPFResult: nil, + expectedDKIMCount: 0, + }, + { + name: "Empty parts with semicolons", + header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "DKIM with short form parameters", + header: "mail.example.com; dkim=pass d=example.com s=selector1", + expectedSPFResult: nil, + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + }, + { + name: "SPF neutral", + header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", + expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "SPF none", + header: "mail.example.com; spf=none", + expectedSPFResult: api.PtrTo(api.AuthResultResultNone), + expectedDKIMCount: 0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check SPF + if tt.expectedSPFResult != nil { + if results.Spf == nil { + t.Errorf("Expected SPF result, got nil") + } else { + if results.Spf.Result != *tt.expectedSPFResult { + t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult) + } + if tt.expectedSPFDomain != nil { + if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain { + var gotDomain string + if results.Spf.Domain != nil { + gotDomain = *results.Spf.Domain + } + t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain) + } + } + } + } else { + if results.Spf != nil { + t.Errorf("Expected no SPF result, got %+v", results.Spf) + } + } + + // Check DKIM count and result + if results.Dkim == nil { + if tt.expectedDKIMCount != 0 { + t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount) + } + } else { + if len(*results.Dkim) != tt.expectedDKIMCount { + t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount) + } + if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 { + if (*results.Dkim)[0].Result != *tt.expectedDKIMResult { + t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult) + } + } + } + + // Check DMARC + if tt.expectedDMARCResult != nil { + if results.Dmarc == nil { + t.Errorf("Expected DMARC result, got nil") + } else { + if results.Dmarc.Result != *tt.expectedDMARCResult { + t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult) + } + if tt.expectedDMARCDomain != nil { + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain { + var gotDomain string + if results.Dmarc.Domain != nil { + gotDomain = *results.Dmarc.Domain + } + t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain) + } + } + } + } else { + if results.Dmarc != nil { + t.Errorf("Expected no DMARC result, got %+v", results.Dmarc) + } + } + + // Check BIMI + if tt.expectedBIMIResult != nil { + if results.Bimi == nil { + t.Errorf("Expected BIMI result, got nil") + } else { + if results.Bimi.Result != *tt.expectedBIMIResult { + t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult) + } + } + } else { + if results.Bimi != nil { + t.Errorf("Expected no BIMI result, got %+v", results.Bimi) + } + } + + // Check ARC + if tt.expectedARCResult != nil { + if results.Arc == nil { + t.Errorf("Expected ARC result, got nil") + } else { + if results.Arc.Result != *tt.expectedARCResult { + t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult) + } + } + } else { + if results.Arc != nil { + t.Errorf("Expected no ARC result, got %+v", results.Arc) + } + } + }) + } +} + +func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { + // This test verifies that only the first occurrence of each auth method is parsed + analyzer := NewAuthenticationAnalyzer() + + t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Spf == nil { + t.Fatal("Expected SPF result, got nil") + } + if results.Spf.Result != api.AuthResultResultPass { + t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) + } + if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { + t.Errorf("Expected domain from first SPF result") + } + }) + + t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dmarc == nil { + t.Fatal("Expected DMARC result, got nil") + } + if results.Dmarc.Result != api.AuthResultResultPass { + t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) + } + if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { + t.Errorf("Expected domain from first DMARC result") + } + }) + + t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; arc=pass; arc=fail" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Arc == nil { + t.Fatal("Expected ARC result, got nil") + } + if results.Arc.Result != api.ARCResultResultPass { + t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result) + } + }) + + t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) { + header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Bimi == nil { + t.Fatal("Expected BIMI result, got nil") + } + if results.Bimi.Result != api.AuthResultResultPass { + t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) + } + if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { + t.Errorf("Expected domain from first BIMI result") + } + }) + + t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { + // DKIM is special - multiple signatures should all be collected + header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(header, results) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 2 { + t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultPass { + t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[1].Result != api.AuthResultResultFail { + t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) + } + }) +} + +func TestParseLegacySPF(t *testing.T) { + tests := []struct { + name string + receivedSPF string + expectedResult api.AuthResultResult + expectedDomain *string + expectNil bool + }{ + { + name: "SPF pass with envelope-from", + receivedSPF: `pass + (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) + receiver=mx.receiver.com; + identity=mailfrom; + envelope-from="user@example.com"; + helo=smtp.example.com; + client-ip=192.0.2.10`, + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("example.com"), + }, + { + name: "SPF fail with sender", + receivedSPF: `fail + (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) + receiver=mx.receiver.com; + identity=mailfrom; + sender="sender@test.com"; + helo=smtp.test.com; + client-ip=192.0.2.20`, + expectedResult: api.AuthResultResultFail, + expectedDomain: api.PtrTo("test.com"), + }, + { + name: "SPF softfail", + receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: api.PtrTo("example.org"), + }, + { + name: "SPF neutral", + receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: api.PtrTo("domain.net"), + }, + { + name: "SPF none", + receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", + expectedResult: api.AuthResultResultNone, + expectedDomain: api.PtrTo("company.io"), + }, + { + name: "SPF temperror", + receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", + expectedResult: api.AuthResultResultTemperror, + expectedDomain: api.PtrTo("shop.example"), + }, + { + name: "SPF permerror", + receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", + expectedResult: api.AuthResultResultPermerror, + expectedDomain: api.PtrTo("invalid.test"), + }, + { + name: "SPF pass without domain extraction", + receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", + expectedResult: api.AuthResultResultPass, + expectedDomain: nil, + }, + { + name: "Empty Received-SPF header", + receivedSPF: "", + expectNil: true, + }, + { + name: "SPF with unquoted envelope-from", + receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("mail.example.net"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with Received-SPF header + email := &EmailMessage{ + Header: make(map[string][]string), + } + if tt.receivedSPF != "" { + email.Header["Received-Spf"] = []string{tt.receivedSPF} + } + + result := analyzer.parseLegacySPF(email) + + if tt.expectNil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result, got nil") + } + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if tt.expectedDomain != nil { + if result.Domain == nil { + t.Errorf("Domain = nil, want %v", *tt.expectedDomain) + } else if *result.Domain != *tt.expectedDomain { + t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) + } + } else { + if result.Domain != nil { + t.Errorf("Domain = %v, want nil", *result.Domain) + } + } + + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } else if *result.Details != tt.receivedSPF { + t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) + } + }) + } +} + func TestValidateARCChain(t *testing.T) { tests := []struct { name string @@ -452,3 +908,244 @@ func TestValidateARCChain(t *testing.T) { }) } } + +func TestParseLegacyDKIM(t *testing.T) { + tests := []struct { + name string + dkimSignatures []string + expectedCount int + expectedDomains []string + expectedSelector []string + }{ + { + name: "Single DKIM signature with domain and selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "Multiple DKIM signatures", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", + "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "example.com"}, + expectedSelector: []string{"selector1", "selector2"}, + }, + { + name: "DKIM signature with different domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", + }, + expectedCount: 1, + expectedDomains: []string{"mail.example.org"}, + expectedSelector: []string{"default"}, + }, + { + name: "DKIM signature with subdomain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", + }, + expectedCount: 1, + expectedDomains: []string{"newsletters.example.com"}, + expectedSelector: []string{"marketing"}, + }, + { + name: "Multiple signatures from different domains", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", + "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "relay.com"}, + expectedSelector: []string{"s1", "s2"}, + }, + { + name: "No DKIM signatures", + dkimSignatures: []string{}, + expectedCount: 0, + expectedDomains: []string{}, + expectedSelector: []string{}, + }, + { + name: "DKIM signature without selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{""}, + }, + { + name: "DKIM signature without domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; s=selector1; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{""}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with whitespace in parameters", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with multiline format", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with ed25519 algorithm", + dkimSignatures: []string{ + "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"ed25519"}, + }, + { + name: "Complex real-world DKIM signature", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", + }, + expectedCount: 1, + expectedDomains: []string{"google.com"}, + expectedSelector: []string{"20230601"}, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with DKIM-Signature headers + email := &EmailMessage{ + Header: make(map[string][]string), + } + if len(tt.dkimSignatures) > 0 { + email.Header["Dkim-Signature"] = tt.dkimSignatures + } + + results := analyzer.parseLegacyDKIM(email) + + // Check count + if len(results) != tt.expectedCount { + t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) + return + } + + // Check each result + for i, result := range results { + // All legacy DKIM results should have Result = none + if result.Result != api.AuthResultResultNone { + t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) + } + + // Check domain + if i < len(tt.expectedDomains) { + expectedDomain := tt.expectedDomains[i] + if expectedDomain != "" { + if result.Domain == nil { + t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) + } else if strings.TrimSpace(*result.Domain) != expectedDomain { + t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) + } + } + } + + // Check selector + if i < len(tt.expectedSelector) { + expectedSelector := tt.expectedSelector[i] + if expectedSelector != "" { + if result.Selector == nil { + t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) + } else if strings.TrimSpace(*result.Selector) != expectedSelector { + t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) + } + } + } + + // Check that Details is set + if result.Details == nil { + t.Errorf("Result[%d].Details = nil, expected non-nil", i) + } else { + expectedDetails := "DKIM signature present (verification status unknown)" + if *result.Details != expectedDetails { + t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) + } + } + } + }) + } +} + +func TestParseLegacyDKIM_Integration(t *testing.T) { + // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication + t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultNone { + t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { + t.Error("Expected domain to be 'example.com'") + } + }) + + t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + // Both Authentication-Results and DKIM-Signature headers + email.Header["Authentication-Results"] = []string{ + "mx.example.com; dkim=pass header.d=verified.com header.s=s1", + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultPass { + t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { + t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") + } + }) +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index ec043cf..0ac750a 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -1,12 +1,13 @@
    -

    - - DNS Records +

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

    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index fb0912f..7555b8c 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -36,6 +36,20 @@ {#if summary}
    +
    +
    + = 100} + class:text-warning={summary.dns_score < 100 && + summary.dns_score >= 50} + class:text-danger={summary.dns_score < 50} + > + {summary.dns_score}% + + DNS +
    +
    - +
    {/if} From e77bffb04f7adb517be6bf41f30613c57ae03d21 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 14:38:38 +0700 Subject: [PATCH 061/256] Improve spamassassin report --- api/openapi.yaml | 39 +++++++ pkg/analyzer/report.go | 18 +--- pkg/analyzer/spamassassin.go | 102 +++++++++++------- pkg/analyzer/spamassassin_test.go | 95 +++++++++------- .../lib/components/SpamAssassinCard.svelte | 50 +++++++-- web/src/routes/test/[test]/+page.svelte | 5 +- 6 files changed, 203 insertions(+), 106 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a3649ef..a35b816 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -674,7 +674,12 @@ components: - score - required_score - is_spam + - test_details properties: + version: + type: string + description: SpamAssassin version + example: "SpamAssassin 4.0.1" score: type: number format: float @@ -695,10 +700,44 @@ components: type: string description: List of triggered SpamAssassin tests example: ["BAYES_00", "DKIM_SIGNED"] + test_details: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of test names to their detailed results + example: + BAYES_00: + name: "BAYES_00" + score: -1.9 + description: "Bayes spam probability is 0 to 1%" + DKIM_SIGNED: + name: "DKIM_SIGNED" + score: 0.1 + description: "Message has a DKIM or DK signature, not necessarily valid" report: type: string description: Full SpamAssassin report + SpamTestDetail: + type: object + required: + - name + - score + properties: + name: + type: string + description: Test name + example: "BAYES_00" + score: + type: number + format: float + description: Score contribution of this test + example: -1.9 + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + DNSResults: type: object required: diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6e38bce..b5a8f16 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -63,7 +63,7 @@ type AnalysisResults struct { DNS *api.DNSResults Headers *api.HeaderAnalysis RBL *RBLResults - SpamAssassin *SpamAssassinResult + SpamAssassin *api.SpamAssassinResult } // AnalyzeEmail performs complete email analysis @@ -157,21 +157,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } // Add SpamAssassin result - if results.SpamAssassin != nil { - report.Spamassassin = &api.SpamAssassinResult{ - Score: float32(results.SpamAssassin.Score), - RequiredScore: float32(results.SpamAssassin.RequiredScore), - IsSpam: results.SpamAssassin.IsSpam, - } - - if len(results.SpamAssassin.Tests) > 0 { - report.Spamassassin.Tests = &results.SpamAssassin.Tests - } - - if results.SpamAssassin.RawReport != "" { - report.Spamassassin.Report = &results.SpamAssassin.RawReport - } - } + report.Spamassassin = results.SpamAssassin // Add raw headers if results.Email != nil && results.Email.RawHeaders != "" { diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index bfc7f50..5e6314b 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -26,6 +26,8 @@ import ( "regexp" "strconv" "strings" + + "git.happydns.org/happyDeliver/internal/api" ) // SpamAssassinAnalyzer analyzes SpamAssassin results from email headers @@ -36,33 +38,15 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { return &SpamAssassinAnalyzer{} } -// SpamAssassinResult represents parsed SpamAssassin results -type SpamAssassinResult struct { - IsSpam bool - Score float64 - RequiredScore float64 - Tests []string - TestDetails map[string]SpamTestDetail - Version string - RawReport string -} - -// SpamTestDetail contains details about a specific spam test -type SpamTestDetail struct { - Name string - Score float64 - Description string -} - // AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers -func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult { +func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult { headers := email.GetSpamAssassinHeaders() if len(headers) == 0 { return nil } - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), } // Parse X-Spam-Status header @@ -73,7 +57,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // Parse X-Spam-Score header (as fallback if not in X-Spam-Status) if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 { if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { - result.Score = score + result.Score = float32(score) } } @@ -84,13 +68,13 @@ 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 = strings.Replace(reportHeader, " * ", "\n* ", -1) + result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1)) a.parseSpamReport(reportHeader, result) } // Parse X-Spam-Checker-Version if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok { - result.Version = strings.TrimSpace(versionHeader) + result.Version = api.PtrTo(strings.TrimSpace(versionHeader)) } return result @@ -98,7 +82,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // parseSpamStatus parses the X-Spam-Status header // Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no -func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) { +func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) { // Check if spam (first word) parts := strings.SplitN(header, ",", 2) if len(parts) > 0 { @@ -110,7 +94,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`) if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 { if score, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.Score = score + result.Score = float32(score) } } @@ -118,19 +102,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`) if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 { if required, err := strconv.ParseFloat(matches[1], 64); err == nil { - result.RequiredScore = required + result.RequiredScore = float32(required) } } // Extract tests - testsRe := regexp.MustCompile(`tests=([^\s]+)`) + testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`) if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 { testsStr := matches[1] // Tests can be comma or space separated tests := strings.FieldsFunc(testsStr, func(r rune) bool { return r == ',' || r == ' ' }) - result.Tests = tests + result.Tests = &tests } } @@ -138,17 +122,20 @@ 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) { - // 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 +// Multiline descriptions continue on lines starting with * but without score: +// * 0.0 TEST_NAME Description line 1 +// * continuation line 2 +// * continuation line 3 +func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) { segments := strings.Split(report, "*") // Regex to match test lines: score TEST_NAME Description // Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description" testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + var currentTestName string + var currentDescription strings.Builder + for _, segment := range segments { segment = strings.TrimSpace(segment) if segment == "" { @@ -158,22 +145,55 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass // Try to match as a test line matches := testRe.FindStringSubmatch(segment) if len(matches) > 3 { + // Save previous test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := api.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } + + // Start new test testName := matches[2] score, _ := strconv.ParseFloat(matches[1], 64) description := strings.TrimSpace(matches[3]) - detail := SpamTestDetail{ - Name: testName, - Score: score, - Description: description, + currentTestName = testName + currentDescription.Reset() + currentDescription.WriteString(description) + + // Initialize with score + result.TestDetails[testName] = api.SpamTestDetail{ + Name: testName, + Score: float32(score), } - result.TestDetails[testName] = detail + } else if currentTestName != "" { + // This is a continuation line for the current test + // Add a space before appending to ensure proper word separation + if currentDescription.Len() > 0 { + currentDescription.WriteString(" ") + } + currentDescription.WriteString(segment) } } + + // Save the last test if exists + if currentTestName != "" { + description := strings.TrimSpace(currentDescription.String()) + detail := api.SpamTestDetail{ + Name: currentTestName, + Score: result.TestDetails[currentTestName].Score, + Description: &description, + } + result.TestDetails[currentTestName] = detail + } } // CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability -func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *SpamAssassinResult) int { +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) int { if result == nil { return 100 // No spam scan results, assume good } @@ -192,6 +212,6 @@ func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *SpamAssassinRe return 0 // Failed spam test } else { // Linear scale between 0 and required threshold - return 100 - int(math.Round(score*100/result.RequiredScore)) + return 100 - int(math.Round(float64(score*100/result.RequiredScore))) } } diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index 2ed2890..16ff854 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -26,6 +26,8 @@ import ( "net/mail" "strings" "testing" + + "git.happydns.org/happyDeliver/internal/api" ) func TestParseSpamStatus(t *testing.T) { @@ -33,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) { name string header string expectedIsSpam bool - expectedScore float64 - expectedReq float64 + expectedScore float32 + expectedReq float32 expectedTests []string }{ { @@ -75,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), } analyzer.parseSpamStatus(tt.header, result) @@ -89,8 +91,12 @@ func TestParseSpamStatus(t *testing.T) { if result.RequiredScore != tt.expectedReq { t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq) } - if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) { - t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests) + if len(tt.expectedTests) > 0 { + if result.Tests == nil { + t.Errorf("Tests = nil, want %v", tt.expectedTests) + } else if !stringSliceEqual(*result.Tests, tt.expectedTests) { + t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests) + } } }) } @@ -109,27 +115,27 @@ func TestParseSpamReport(t *testing.T) { ` analyzer := NewSpamAssassinAnalyzer() - result := &SpamAssassinResult{ - TestDetails: make(map[string]SpamTestDetail), + result := &api.SpamAssassinResult{ + TestDetails: make(map[string]api.SpamTestDetail), } analyzer.parseSpamReport(report, result) - expectedTests := map[string]SpamTestDetail{ + expectedTests := map[string]api.SpamTestDetail{ "BAYES_99": { Name: "BAYES_99", Score: 5.0, - Description: "Bayes spam probability is 99 to 100%", + Description: api.PtrTo("Bayes spam probability is 99 to 100%"), }, "SPOOFED_SENDER": { Name: "SPOOFED_SENDER", Score: 3.5, - Description: "From address doesn't match envelope sender", + Description: api.PtrTo("From address doesn't match envelope sender"), }, "ALL_TRUSTED": { Name: "ALL_TRUSTED", Score: -1.0, - Description: "All mail servers are trusted", + Description: api.PtrTo("All mail servers are trusted"), }, } @@ -142,8 +148,8 @@ func TestParseSpamReport(t *testing.T) { if detail.Score != expected.Score { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score) } - if detail.Description != expected.Description { - t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description) + if *detail.Description != *expected.Description { + t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description) } } } @@ -151,7 +157,7 @@ func TestParseSpamReport(t *testing.T) { func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string - result *SpamAssassinResult + result *api.SpamAssassinResult expectedScore int minScore int maxScore int @@ -159,11 +165,11 @@ func TestGetSpamAssassinScore(t *testing.T) { { name: "Nil result", result: nil, - expectedScore: 0, + expectedScore: 100, }, { name: "Excellent score (negative)", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: -2.5, RequiredScore: 5.0, }, @@ -171,38 +177,43 @@ func TestGetSpamAssassinScore(t *testing.T) { }, { name: "Good score (below threshold)", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 2.0, RequiredScore: 5.0, }, - minScore: 80, - maxScore: 100, + expectedScore: 60, // 100 - round(2*100/5) = 100 - 40 = 60 }, { - name: "Borderline (just above threshold)", - result: &SpamAssassinResult{ + name: "Score at threshold", + result: &api.SpamAssassinResult{ + Score: 5.0, + RequiredScore: 5.0, + }, + expectedScore: 0, // >= threshold = 0 + }, + { + name: "Above threshold (spam)", + result: &api.SpamAssassinResult{ Score: 6.0, RequiredScore: 5.0, }, - minScore: 60, - maxScore: 80, + expectedScore: 0, // >= threshold = 0 }, { name: "High spam score", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 12.0, RequiredScore: 5.0, }, - minScore: 20, - maxScore: 50, + expectedScore: 0, // >= threshold = 0 }, { name: "Very high spam score", - result: &SpamAssassinResult{ + result: &api.SpamAssassinResult{ Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0, + expectedScore: 0, // >= threshold = 0 }, } @@ -210,7 +221,7 @@ func TestGetSpamAssassinScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := analyzer.GetSpamAssassinScore(tt.result) + score, _ := analyzer.CalculateSpamAssassinScore(tt.result) if tt.minScore > 0 || tt.maxScore > 0 { if score < tt.minScore || score > tt.maxScore { @@ -230,7 +241,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) { name string headers map[string]string expectedIsSpam bool - expectedScore float64 + expectedScore float32 expectedHasDetails bool }{ { @@ -370,24 +381,26 @@ func TestAnalyzeRealEmailExample(t *testing.T) { } // Validate score (should be -0.1) - expectedScore := -0.1 + var expectedScore float32 = -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 + var expectedRequired float32 = 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) + if result.Version == nil { + t.Errorf("Version should contain 'SpamAssassin', got: nil") + } else if !strings.Contains(*result.Version, "SpamAssassin") { + t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version) } // Validate that tests were extracted - if len(result.Tests) == 0 { + if len(*result.Tests) == 0 { t.Error("Expected tests to be extracted, got none") } @@ -400,7 +413,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) { "SPF_HELO_NONE": true, } - for _, testName := range result.Tests { + for _, testName := range *result.Tests { if expectedTests[testName] { t.Logf("Found expected test: %s", testName) } @@ -414,11 +427,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Log what we actually got for debugging t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails)) for name, detail := range result.TestDetails { - t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description) + t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description) } // Define expected test details with their scores - expectedTestDetails := map[string]float64{ + expectedTestDetails := map[string]float32{ "SPF_PASS": -0.0, "SPF_HELO_NONE": 0.0, "DKIM_VALID": -0.1, @@ -439,13 +452,13 @@ func TestAnalyzeRealEmailExample(t *testing.T) { if detail.Score != expectedScore { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore) } - if detail.Description == "" { + if detail.Description == nil || *detail.Description == "" { t.Errorf("Test %s should have a description", testName) } } // Test GetSpamAssassinScore - score := analyzer.GetSpamAssassinScore(result) + score, _ := analyzer.CalculateSpamAssassinScore(result) if score != 100 { t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) } diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index 3d4872c..0413aa4 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -3,16 +3,25 @@ interface Props { spamassassin: SpamAssassinResult; + spamScore: number; } - let { spamassassin }: Props = $props(); + let { spamassassin, spamScore }: Props = $props();
    -
    -
    - SpamAssassin Analysis -
    +
    +

    + + + SpamAssassin Analysis + + {#if spamScore !== undefined} + + {spamScore}% + + {/if} +

    @@ -30,7 +39,34 @@
    - {#if spamassassin.tests && spamassassin.tests.length > 0} + {#if spamassassin.test_details && Object.keys(spamassassin.test_details).length > 0} +
    +
    + + + + + + + + + + {#each Object.entries(spamassassin.test_details) as [testName, detail]} + 0 ? 'table-warning' : detail.score < 0 ? 'table-success' : ''}> + + + + + {/each} + +
    Test NameScoreDescription
    {testName} + 0 ? 'text-danger fw-bold' : detail.score < 0 ? 'text-success fw-bold' : 'text-muted'}> + {detail.score > 0 ? '+' : ''}{detail.score.toFixed(1)} + + {detail.description || ''}
    +
    +
    + {:else if spamassassin.tests && spamassassin.tests.length > 0}
    Tests Triggered:
    @@ -43,7 +79,7 @@ {#if spamassassin.report}
    - Full Report + Raw Report
    {spamassassin.report}
    {/if} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 45130f1..4ce53c5 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -197,7 +197,10 @@ {#if report.spamassassin}
    - +
    {/if} From 41013d8af2444fe9e5715e8e77d407848fd93b3f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 15:05:47 +0700 Subject: [PATCH 062/256] Improve headers reporting --- pkg/analyzer/headers.go | 42 ++++++++++++++++--- .../lib/components/HeaderAnalysisCard.svelte | 9 +++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 4ffc1a3..1dd0302 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -63,7 +63,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int } // Check recommended headers (30 points) - recommendedHeaders := []string{"subject", "to", "reply-to"} + recommendedHeaders := []string{"subject", "to"} + + // Add reply-to when from is a no-reply address + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } + recommendedCount := len(recommendedHeaders) presentRecommended := 0 @@ -72,7 +78,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int presentRecommended++ } } - score += int(30 * (float32(presentRecommended) / float32(recommendedCount))) + score += presentRecommended * 30 / recommendedCount // Check for proper MIME structure (20 points) if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { @@ -120,6 +126,29 @@ func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { return len(parts[0]) > 0 && len(parts[1]) > 0 } +// isNoReplyAddress checks if a header check represents a no-reply email address +func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool { + if !headerCheck.Present || headerCheck.Value == nil { + return false + } + + value := strings.ToLower(*headerCheck.Value) + noReplyPatterns := []string{ + "no-reply", + "noreply", + "ne-pas-repondre", + "nepasrepondre", + } + + for _, pattern := range noReplyPatterns { + if strings.Contains(value, pattern) { + return true + } + } + + return false +} + // GenerateHeaderAnalysis creates structured header analysis from email func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { if email == nil { @@ -142,16 +171,19 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header } // Check recommended headers - recommendedHeaders := []string{"Reply-To", "Return-Path"} + recommendedHeaders := []string{} + if h.isNoReplyAddress(headers["from"]) { + recommendedHeaders = append(recommendedHeaders, "reply-to") + } for _, headerName := range recommendedHeaders { check := h.checkHeader(email, headerName, "recommended") headers[strings.ToLower(headerName)] = *check } // Check optional headers - optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post", "Precedence"} + optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"} for _, headerName := range optionalHeaders { - check := h.checkHeader(email, headerName, "optional") + check := h.checkHeader(email, headerName, "newsletter") headers[strings.ToLower(headerName)] = *check } diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 6b03966..12a74e1 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -98,7 +98,12 @@ - {#each Object.entries(headerAnalysis.headers) as [name, check]} + {#each Object.entries(headerAnalysis.headers).sort((a, b) => { + const importanceOrder = { 'required': 0, 'recommended': 1, 'optional': 2, 'newsletter': 3 }; + const aImportance = importanceOrder[a[1].importance || 'optional']; + const bImportance = importanceOrder[b[1].importance || 'optional']; + return aImportance - bImportance; + }) as [name, check]} {name} @@ -112,7 +117,7 @@ - {#if check.valid !== undefined} + {#if check.present && check.valid !== undefined} {:else} - From 33d394a27bed0961c3d0520a63e68acae2a81219 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 16:16:32 +0700 Subject: [PATCH 063/256] Improve content analyzing and reporting --- api/openapi.yaml | 2 +- pkg/analyzer/content.go | 164 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a35b816..aed3de4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -392,7 +392,7 @@ components: properties: type: type: string - enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link] + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] description: Type of content issue example: "missing_alt" severity: diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 7964693..74f6b2a 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -63,6 +63,7 @@ func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { // ContentResults represents content analysis results type ContentResults struct { + IsMultipart bool HTMLValid bool HTMLErrors []string Links []LinkCheck @@ -75,6 +76,12 @@ type ContentResults struct { ImageTextRatio float32 // Ratio of images to text SuspiciousURLs []string ContentIssues []string + HarmfullIssues []string +} + +// HasPlaintext returns true if the email has plain text content +func (r *ContentResults) HasPlaintext() bool { + return r.TextContent != "" } // LinkCheck represents a link validation result @@ -101,6 +108,8 @@ type ImageCheck struct { func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { results := &ContentResults{} + results.IsMultipart = len(email.Parts) > 1 + // Get HTML and text parts htmlParts := email.GetHTMLParts() textParts := email.GetTextParts() @@ -117,16 +126,57 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { for _, part := range textParts { results.TextContent += part.Content } + // Extract and validate links from plain text + c.analyzeTextLinks(results.TextContent, results) } // Check plain text/HTML consistency if len(htmlParts) > 0 && len(textParts) > 0 { results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) + } else if !results.IsMultipart { + results.TextPlainRatio = 1.0 } return results } +// analyzeTextLinks extracts and validates URLs from plain text +func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) { + // Regular expression to match URLs in plain text + // Matches http://, https://, and www. URLs + urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`) + + matches := urlRegex.FindAllString(textContent, -1) + + for _, match := range matches { + // Normalize URL (add http:// if missing) + urlStr := match + if strings.HasPrefix(strings.ToLower(urlStr), "www.") { + urlStr = "http://" + urlStr + } + + // Check if this URL already exists in results.Links (from HTML analysis) + exists := false + for _, link := range results.Links { + if link.URL == urlStr { + exists = true + break + } + } + + // Only validate if not already checked + if !exists { + linkCheck := c.validateLink(urlStr) + results.Links = append(results.Links, linkCheck) + + // Check for suspicious URLs + if !linkCheck.IsSafe { + results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr) + } + } + } +} + // analyzeHTML parses and analyzes HTML content func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { results.HTMLContent = htmlContent @@ -195,6 +245,59 @@ func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { } results.Images = append(results.Images, imageCheck) + + case "script": + // JavaScript in emails is a security risk and typically blocked + results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous
    @@ -16,11 +19,16 @@ Blacklist Checks - {#if blacklistScore !== undefined} - - {blacklistScore}% - - {/if} + + {#if blacklistScore !== undefined} + + {blacklistScore}% + + {/if} + {#if blacklistGrade !== undefined} + + {/if} +
    diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index 6ee4cbb..3b7bc95 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -1,12 +1,15 @@
    @@ -16,11 +19,16 @@ Content Analysis - {#if contentScore !== undefined} - - {contentScore}% - - {/if} + + {#if contentScore !== undefined} + + {contentScore}% + + {/if} + {#if contentGrade !== undefined} + + {/if} +
    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index ac5e68f..08f9c87 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,12 +1,15 @@
    @@ -16,11 +19,16 @@ DNS Records - {#if dnsScore !== undefined} - - {dnsScore}% - - {/if} + + {#if dnsScore !== undefined} + + {dnsScore}% + + {/if} + {#if dnsGrade !== undefined} + + {/if} +
    diff --git a/web/src/lib/components/GradeDisplay.svelte b/web/src/lib/components/GradeDisplay.svelte new file mode 100644 index 0000000..322259b --- /dev/null +++ b/web/src/lib/components/GradeDisplay.svelte @@ -0,0 +1,61 @@ + + + + {#if grade} + {grade} + {:else} + {score}% + {/if} + + + diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 12a74e1..8dd074f 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -1,12 +1,15 @@
    @@ -16,11 +19,16 @@ Header Analysis - {#if headerScore !== undefined} - - {headerScore}% - - {/if} + + {#if headerScore !== undefined} + + {headerScore}% + + {/if} + {#if headerGrade !== undefined} + + {/if} +
    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 7555b8c..d360c31 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,5 +1,6 @@
    @@ -16,11 +19,16 @@ SpamAssassin Analysis - {#if spamScore !== undefined} - - {spamScore}% - - {/if} + + {#if spamScore !== undefined} + + {spamScore}% + + {/if} + {#if spamGrade !== undefined} + + {/if} +
    diff --git a/web/src/lib/score.ts b/web/src/lib/score.ts new file mode 100644 index 0000000..e9d9bae --- /dev/null +++ b/web/src/lib/score.ts @@ -0,0 +1,5 @@ +export function getScoreColorClass(percentage: number): string { + if (percentage >= 85) return "success"; + if (percentage >= 50) return "warning"; + return "danger"; +} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 4ce53c5..c80cd0b 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -81,12 +81,6 @@ stopPolling(); }); - function getScoreColorClass(percentage: number): string { - if (percentage >= 80) return "text-success"; - if (percentage >= 50) return "text-warning"; - return "text-danger"; - } - async function handleReanalyze() { if (!testId || reanalyzing) return; @@ -150,6 +144,7 @@
    @@ -162,6 +157,7 @@
    @@ -175,6 +171,7 @@
    @@ -187,6 +184,7 @@
    @@ -199,6 +197,7 @@
    @@ -211,6 +210,7 @@
    From f6a1ea73a2335492cf01d12a44f1a48e12455cfc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 17:42:41 +0700 Subject: [PATCH 065/256] Check SPF include --- api/openapi.yaml | 11 +- pkg/analyzer/dns.go | 142 ++++++++++++++++--- web/src/lib/components/DnsRecordsCard.svelte | 55 ++++--- 3 files changed, 161 insertions(+), 47 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 9e33d64..9c682dc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -788,8 +788,11 @@ components: items: $ref: '#/components/schemas/MXRecord' description: MX records for the domain - spf_record: - $ref: '#/components/schemas/SPFRecord' + spf_records: + type: array + items: + $ref: '#/components/schemas/SPFRecord' + description: SPF records found (includes resolved include directives) dkim_records: type: array items: @@ -835,6 +838,10 @@ components: required: - valid properties: + domain: + type: string + description: Domain this SPF record belongs to + example: "example.com" record: type: string description: SPF record content diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 2a7828c..303c095 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -68,8 +68,8 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // Check MX records results.MxRecords = d.checkMXRecords(domain) - // Check SPF record - results.SpfRecord = d.checkSPFRecord(domain) + // Check SPF records (including includes) + results.SpfRecords = d.checkSPFRecords(domain) // Check DKIM records (from authentication results) if authResults != nil && authResults.Dkim != nil { @@ -142,16 +142,43 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { return &results } -// checkSPFRecord looks up and validates SPF record for a domain -func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord { +// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { + visited := make(map[string]bool) + return d.resolveSPFRecords(domain, visited, 0) +} + +// resolveSPFRecords recursively resolves SPF records including include: directives +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { + const maxDepth = 10 // Prevent infinite recursion + + if depth > maxDepth { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("Maximum SPF include depth exceeded"), + }, + } + } + + // Prevent circular references + if visited[domain] { + return &[]api.SPFRecord{} + } + visited[domain] = true + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { - return &api.SPFRecord{ - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + }, } } @@ -166,33 +193,77 @@ func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord { } if spfCount == 0 { - return &api.SPFRecord{ - Valid: false, - Error: api.PtrTo("No SPF record found"), + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("No SPF record found"), + }, } } + var results []api.SPFRecord + if spfCount > 1 { - return &api.SPFRecord{ + results = append(results, api.SPFRecord{ + Domain: &domain, Record: &spfRecord, Valid: false, Error: api.PtrTo("Multiple SPF records found (RFC violation)"), - } + }) + return &results } // Basic validation - if !d.validateSPF(spfRecord) { - return &api.SPFRecord{ - Record: &spfRecord, - Valid: false, - Error: api.PtrTo("SPF record appears malformed"), + valid := d.validateSPF(spfRecord) + + // Check for strict -all mechanism + var errMsg *string + if !valid { + errMsg = api.PtrTo("SPF record appears malformed") + } else if !d.hasSPFStrictFail(spfRecord) { + // Check what mechanism is used + if strings.HasSuffix(spfRecord, " ~all") { + errMsg = api.PtrTo("SPF uses ~all (softfail) instead of -all (hardfail). This weakens email authentication and may reduce deliverability.") + } else if strings.HasSuffix(spfRecord, " +all") || strings.HasSuffix(spfRecord, " ?all") { + errMsg = api.PtrTo("SPF uses permissive 'all' mechanism. This severely weakens email authentication. Use -all for strict policy.") + } else if strings.HasSuffix(spfRecord, " all") { + errMsg = api.PtrTo("SPF uses neutral 'all' mechanism. Use -all for strict policy to improve deliverability.") + } else { + errMsg = api.PtrTo("SPF record should end with -all for strict policy to improve deliverability and prevent spoofing.") } } - return &api.SPFRecord{ + results = append(results, api.SPFRecord{ + Domain: &domain, Record: &spfRecord, - Valid: true, + Valid: valid, + Error: errMsg, + }) + + // Extract and resolve include: directives + includes := d.extractSPFIncludes(spfRecord) + for _, includeDomain := range includes { + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) + if includedRecords != nil { + results = append(results, *includedRecords...) + } } + + return &results +} + +// extractSPFIncludes extracts all include: domains from an SPF record +func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { + var includes []string + re := regexp.MustCompile(`include:([^\s]+)`) + matches := re.FindAllStringSubmatch(record, -1) + for _, match := range matches { + if len(match) > 1 { + includes = append(includes, match[1]) + } + } + return includes } // validateSPF performs basic SPF record validation @@ -216,6 +287,11 @@ func (d *DNSAnalyzer) validateSPF(record string) bool { return hasValidEnding } +// hasSPFStrictFail checks if SPF record has strict -all mechanism +func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { + return strings.HasSuffix(record, " -all") +} + // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain @@ -468,12 +544,32 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { } } - // SPF Record: 20 points + // SPF Records: 20 points // SPF is essential for email authentication - if results.SpfRecord != nil { - if results.SpfRecord.Valid { + if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { + // Check the main domain's SPF record (first in the list) + mainSPF := (*results.SpfRecords)[0] + if mainSPF.Valid { + // Full points for valid SPF score += 20 - } else if results.SpfRecord.Record != nil { + + // Check for strict -all mechanism + if mainSPF.Record != nil && !d.hasSPFStrictFail(*mainSPF.Record) { + // Deduct points for weak SPF policy + if strings.HasSuffix(*mainSPF.Record, " ~all") { + // Softfail - moderate penalty + score -= 5 + } else if strings.HasSuffix(*mainSPF.Record, " +all") || + strings.HasSuffix(*mainSPF.Record, " ?all") || + strings.HasSuffix(*mainSPF.Record, " all") { + // Pass/neutral - severe penalty + score -= 10 + } else { + // No 'all' mechanism at all - severe penalty + score -= 10 + } + } + } else if mainSPF.Record != nil { // Partial credit if SPF record exists but has issues score += 5 } diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 08f9c87..f4ff358 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -88,35 +88,46 @@
    {/if} - - {#if dnsResults.spf_record} + + {#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
    SPF Sender Policy Framework
    -
    -
    -
    - Status: - {#if dnsResults.spf_record.valid} - Valid - {:else} - Invalid + {#each dnsResults.spf_records as spf, index} +
    +
    + {#if spf.domain} +
    + Domain: {spf.domain} + {#if index > 0} + Included + {/if} +
    + {/if} +
    + Status: + {#if spf.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if spf.record} +
    + Record:
    + {spf.record} +
    + {/if} + {#if spf.error} +
    + + {spf.valid ? 'Warning:' : 'Error:'} {spf.error} +
    {/if}
    - {#if dnsResults.spf_record.record} -
    - Record:
    - {dnsResults.spf_record.record} -
    - {/if} - {#if dnsResults.spf_record.error} -
    - Error: {dnsResults.spf_record.error} -
    - {/if}
    -
    + {/each}
    {/if} From 8ca4bed875c04b5bfeb6eac21e9521b782ddd40d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 18:38:24 +0700 Subject: [PATCH 066/256] SPF check return-path --- api/openapi.yaml | 19 +++- pkg/analyzer/dns.go | 104 ++++++++++++++---- pkg/analyzer/dns_test.go | 4 +- pkg/analyzer/headers.go | 17 ++- web/src/lib/components/DnsRecordsCard.svelte | 84 +++++++------- .../lib/components/HeaderAnalysisCard.svelte | 16 +-- .../lib/components/MxRecordsDisplay.svelte | 49 +++++++++ 7 files changed, 210 insertions(+), 83 deletions(-) create mode 100644 web/src/lib/components/MxRecordsDisplay.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 9c682dc..6012ba0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -777,17 +777,26 @@ components: DNSResults: type: object required: - - domain + - from_domain properties: - domain: + from_domain: type: string - description: Domain name + description: From Domain name example: "example.com" - mx_records: + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: type: array items: $ref: '#/components/schemas/MXRecord' - description: MX records for the domain + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain spf_records: type: array items: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 303c095..9dc12fa 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -54,24 +54,40 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults { // Extract domain from From address - domain := d.extractDomain(email) - if domain == "" { + fromDomain := d.extractFromDomain(email) + if fromDomain == "" { return &api.DNSResults{ Errors: &[]string{"Unable to extract domain from email"}, } } results := &api.DNSResults{ - Domain: domain, + FromDomain: fromDomain, + RpDomain: d.extractRPDomain(email), } - // Check MX records - results.MxRecords = d.checkMXRecords(domain) + // Determine which domain to check SPF for (Return-Path domain) + // SPF validates the envelope sender (Return-Path), not the From header + spfDomain := fromDomain + if results.RpDomain != nil { + spfDomain = *results.RpDomain + } - // Check SPF records (including includes) - results.SpfRecords = d.checkSPFRecords(domain) + // Check MX records for From domain (where replies would go) + results.FromMxRecords = d.checkMXRecords(fromDomain) + + // Check MX records for Return-Path domain (where bounces would go) + // Only check if Return-Path domain is different from From domain + if results.RpDomain != nil && *results.RpDomain != fromDomain { + results.RpMxRecords = d.checkMXRecords(*results.RpDomain) + } + + // Check SPF records (for Return-Path domain - this is the envelope sender) + // SPF validates the MAIL FROM command, which corresponds to Return-Path + results.SpfRecords = d.checkSPFRecords(spfDomain) // Check DKIM records (from authentication results) + // DKIM can be for any domain, but typically the From domain if authResults != nil && authResults.Dkim != nil { for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && dkim.Selector != nil { @@ -86,17 +102,18 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic } } - // Check DMARC record - results.DmarcRecord = d.checkDMARCRecord(domain) + // Check DMARC record (for From domain - DMARC protects the visible sender) + // DMARC validates alignment between SPF/DKIM and the From domain + results.DmarcRecord = d.checkDMARCRecord(fromDomain) - // Check BIMI record (using default selector) - results.BimiRecord = d.checkBIMIRecord(domain, "default") + // Check BIMI record (for From domain - branding is based on visible sender) + results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") return results } -// extractDomain extracts the domain from the email's From address -func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { +// extractFromDomain extracts the domain from the email's From address +func (d *DNSAnalyzer) extractFromDomain(email *EmailMessage) string { if email.From != nil && email.From.Address != "" { parts := strings.Split(email.From.Address, "@") if len(parts) == 2 { @@ -106,6 +123,17 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { return "" } +// extractRPDomain extracts the domain from the email's Return-Path address +func (d *DNSAnalyzer) extractRPDomain(email *EmailMessage) *string { + if email.ReturnPath != "" { + parts := strings.Split(email.ReturnPath, "@") + if len(parts) == 2 { + return api.PtrTo(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(parts[1])), ">")) + } + } + return nil +} + // checkMXRecords looks up MX records for a domain func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) @@ -529,18 +557,50 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // TODO: 20 points for correct PTR and A/AAAA - // MX Records: 20 points + // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) // Having valid MX records is critical for email deliverability - if results.MxRecords != nil && len(*results.MxRecords) > 0 { - hasValidMX := false - for _, mx := range *results.MxRecords { + // From domain MX records (10 points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { if mx.Valid { - hasValidMX = true + hasValidFromMX = true break } } - if hasValidMX { - score += 20 + if hasValidFromMX { + score += 10 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 10 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 10 + } } } @@ -560,8 +620,8 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // Softfail - moderate penalty score -= 5 } else if strings.HasSuffix(*mainSPF.Record, " +all") || - strings.HasSuffix(*mainSPF.Record, " ?all") || - strings.HasSuffix(*mainSPF.Record, " all") { + strings.HasSuffix(*mainSPF.Record, " ?all") || + strings.HasSuffix(*mainSPF.Record, " all") { // Pass/neutral - severe penalty score -= 10 } else { diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 7859523..664ae5e 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -104,9 +104,9 @@ func TestExtractDomain(t *testing.T) { } } - domain := analyzer.extractDomain(email) + domain := analyzer.extractFromDomain(email) if domain != tt.expectedDomain { - t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain) + t.Errorf("extractFromDomain() = %q, want %q", domain, tt.expectedDomain) } }) } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 1fc18dd..4364218 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -46,7 +46,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade := 6 headers := *analysis.Headers - // Check required headers (RFC 5322) - 40 points + // RP and From alignment (20 points) + if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { + score += 20 + } else { + maxGrade -= 2 + } + + // Check required headers (RFC 5322) - 30 points requiredHeaders := []string{"from", "date", "message-id"} requiredCount := len(requiredHeaders) presentRequired := 0 @@ -58,13 +65,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int } if presentRequired == requiredCount { - score += 40 + score += 30 } else { - score += int(40 * (float32(presentRequired) / float32(requiredCount))) + score += int(30 * (float32(presentRequired) / float32(requiredCount))) maxGrade = 1 } - // Check recommended headers (30 points) + // Check recommended headers (20 points) recommendedHeaders := []string{"subject", "to"} // Add reply-to when from is a no-reply address @@ -80,7 +87,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int presentRecommended++ } } - score += presentRecommended * 30 / recommendedCount + score += presentRecommended * 20 / recommendedCount if presentRecommended < recommendedCount { maxGrade -= 1 diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index f4ff358..be2dd1d 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -2,6 +2,7 @@ import type { DNSResults } from "$lib/api/types.gen"; import { getScoreColorClass } from "$lib/score"; import GradeDisplay from "./GradeDisplay.svelte"; + import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; interface Props { dnsResults?: DNSResults; @@ -35,10 +36,6 @@ {#if !dnsResults}

    No DNS results available

    {:else} -
    - Domain: {dnsResults.domain} -
    - {#if dnsResults.errors && dnsResults.errors.length > 0}
    Errors: @@ -50,50 +47,36 @@
    {/if} - - {#if dnsResults.mx_records && dnsResults.mx_records.length > 0} -
    -
    - MX Mail Exchange Records -
    -
    - - - - - - - - - - {#each dnsResults.mx_records as mx} - - - - - - {/each} - -
    PriorityHostStatus
    {mx.priority}{mx.host} - {#if mx.valid} - Valid - {:else} - Invalid - {#if mx.error} -
    {mx.error} - {/if} - {/if} -
    -
    -
    + +
    + Return-Path Domain: {dnsResults.rp_domain || dnsResults.from_domain} + {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} + Different from From domain + + + See domain alignment + + {:else} + Same as From domain + {/if} +
    + + + {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} + {/if} - + {#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
    SPF Sender Policy Framework
    +

    SPF validates the Return-Path (envelope sender) domain.

    {#each dnsResults.spf_records as spf, index}
    @@ -131,6 +114,25 @@
    {/if} +
    + + +
    + From Domain: {dnsResults.from_domain} + {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} + Different from Return-Path domain + {/if} +
    + + + {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} + + {/if} + {#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0}
    diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 8dd074f..382da56 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -56,11 +56,18 @@ {/if} {#if headerAnalysis.domain_alignment} -
    +
    Domain Alignment
    +
    + Aligned +
    + + {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} +
    +
    From Domain
    {headerAnalysis.domain_alignment.from_domain || '-'}
    @@ -69,13 +76,6 @@ Return-Path Domain
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    -
    - Aligned -
    - - {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} -
    -
    {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
    diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte new file mode 100644 index 0000000..55fd7df --- /dev/null +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -0,0 +1,49 @@ + + +
    +
    + MX {title} +
    + {#if description} +

    {description}

    + {/if} +
    + + + + + + + + + + {#each mxRecords as mx} + + + + + + {/each} + +
    PriorityHostStatus
    {mx.priority}{mx.host} + {#if mx.valid} + Valid + {:else} + Invalid + {#if mx.error} +
    {mx.error} + {/if} + {/if} +
    +
    +
    From 4149a5de92cf92d38a7d7faed1bff07d5cfd06d7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 19:00:02 +0700 Subject: [PATCH 067/256] Truncate DKIM record --- web/src/lib/components/DnsRecordsCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index be2dd1d..0a4c0f8 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -157,7 +157,7 @@ {#if dkim.record}
    Record:
    - {dkim.record} + {dkim.record}
    {/if} {#if dkim.error} From a97729fea6e7554b9023af87684d538cb1d22876 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 22:41:11 +0700 Subject: [PATCH 068/256] Tests design and descriptions --- web/src/app.css | 13 +- .../lib/components/AuthenticationCard.svelte | 14 +- web/src/lib/components/BlacklistCard.svelte | 36 ++-- web/src/lib/components/DnsRecordsCard.svelte | 173 ++++++++++++------ .../lib/components/HeaderAnalysisCard.svelte | 79 ++++---- .../lib/components/MxRecordsDisplay.svelte | 77 ++++---- .../lib/components/SpamAssassinCard.svelte | 2 +- 7 files changed, 230 insertions(+), 164 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index ddae5b6..1472994 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -74,14 +74,21 @@ body { /* Custom card styling */ .card { - border: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease; } -.card:hover { +.card:not(.fade-in .card) { + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.fade-in .card:not(.card .card) { + border: none; +} + +.card:hover:not(.fade-in .card) { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 13ae525..b44c102 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -72,10 +72,9 @@
    -
    -
    +
    -
    +
    {#if authentication.spf} @@ -108,7 +107,7 @@
    -
    +
    {#if authentication.dkim && authentication.dkim.length > 0} @@ -147,7 +146,7 @@
    -
    +
    {#if authentication.dmarc} @@ -200,7 +199,7 @@
    -
    +
    {#if authentication.bimi && authentication.bimi.result != "none"} @@ -240,7 +239,7 @@ {#if authentication.arc} -
    +
    @@ -258,6 +257,5 @@
    {/if} -
    diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 94ff2b8..1e5fd2a 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -35,32 +35,24 @@
    {#each Object.entries(blacklists) as [ip, checks]}
    -
    +
    {ip} -
    -
    - - + +
    + + {#each checks as check} - - + + - - - {#each checks as check} - - - - - {/each} - -
    RBLStatus + + {check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')} + + {check.rbl}
    {check.rbl} - - {check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')} - -
    -
    + {/each} + +
    {/each}
    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 0a4c0f8..884d2c4 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -48,8 +48,10 @@ {/if} -
    - Return-Path Domain: {dnsResults.rp_domain || dnsResults.from_domain} +
    +

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

    {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} Different from From domain @@ -64,6 +66,7 @@ {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} {#if dnsResults.spf_records && dnsResults.spf_records.length > 0} -
    -
    - SPF Sender Policy Framework -
    -

    SPF validates the Return-Path (envelope sender) domain.

    - {#each dnsResults.spf_records as spf, index} -
    -
    + {@const spfIsValid = dnsResults.spf_records.reduce((acc, r) => acc && r.valid, true)} +
    +
    +
    + + Sender Policy Framework +
    + SPF +
    +
    +

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

    +
    +
    + {#each dnsResults.spf_records as spf, index} +
    {#if spf.domain}
    Domain: {spf.domain} @@ -109,16 +125,18 @@
    {/if}
    -
    - {/each} + {/each} +
    {/if} -
    +
    -
    - From Domain: {dnsResults.from_domain} +
    +

    + From Domain: {dnsResults.from_domain} +

    {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} Different from Return-Path domain {/if} @@ -127,6 +145,7 @@ {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} {#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0} -
    -
    - DKIM DomainKeys Identified Mail -
    - {#each dnsResults.dkim_records as dkim} -
    -
    + {@const dkimIsValid = dnsResults.dkim_records.reduce((acc, r) => acc && r.valid, true)} +
    +
    +
    + + DomainKeys Identified Mail +
    + DKIM +
    +
    +

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

    +
    +
    + {#each dnsResults.dkim_records as dkim} +
    Selector: {dkim.selector} Domain: {dkim.domain} @@ -166,59 +199,79 @@
    {/if}
    -
    - {/each} + {/each} +
    {/if} {#if dnsResults.dmarc_record} -
    -
    - DMARC Domain-based Message Authentication -
    -
    -
    -
    - Status: - {#if dnsResults.dmarc_record.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if dnsResults.dmarc_record.policy} -
    - Policy: - - {dnsResults.dmarc_record.policy} - -
    - {/if} - {#if dnsResults.dmarc_record.record} -
    - Record:
    - {dnsResults.dmarc_record.record} -
    - {/if} - {#if dnsResults.dmarc_record.error} -
    - Error: {dnsResults.dmarc_record.error} -
    +
    +
    +
    + + Domain-based Message Authentication +
    + DMARC +
    +
    +

    DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

    +
    + Status: + {#if dnsResults.dmarc_record.valid} + Valid + {:else} + Invalid {/if}
    + {#if dnsResults.dmarc_record.policy} +
    + Policy: + + {dnsResults.dmarc_record.policy} + +
    + {/if} + {#if dnsResults.dmarc_record.record} +
    + Record:
    + {dnsResults.dmarc_record.record} +
    + {/if} + {#if dnsResults.dmarc_record.error} +
    + Error: {dnsResults.dmarc_record.error} +
    + {/if}
    {/if} {#if dnsResults.bimi_record} -
    -
    - BIMI Brand Indicators for Message Identification -
    +
    +
    +
    + + Brand Indicators for Message Identification +
    + BIMI +
    +

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

    Selector: {dnsResults.bimi_record.selector} Domain: {dnsResults.bimi_record.domain} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 382da56..9a7857c 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -34,7 +34,7 @@
    {#if headerAnalysis.issues && headerAnalysis.issues.length > 0}
    -
    Issues
    +
    Issues
    {#each headerAnalysis.issues as issue}
    @@ -56,50 +56,59 @@ {/if} {#if headerAnalysis.domain_alignment} -
    -
    Domain Alignment
    -
    -
    -
    -
    - Aligned -
    - +
    +
    +
    + + Domain Alignment +
    +
    +
    +

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

    +
    +
    + Aligned +
    + + {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} -
    -
    -
    - From Domain -
    {headerAnalysis.domain_alignment.from_domain || '-'}
    -
    -
    - Return-Path Domain -
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    +
    - {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
    - {#each headerAnalysis.domain_alignment.issues as issue} -
    - - {issue} -
    - {/each} -
    - {/if} +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    +
    +
    + Return-Path Domain +
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    +
    + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
    + {#each headerAnalysis.domain_alignment.issues as issue} +
    + + {issue} +
    + {/each} +
    + {/if}
    {/if} {#if headerAnalysis.headers && Object.keys(headerAnalysis.headers).length > 0}
    -
    Headers
    +
    Headers
    - + + @@ -115,10 +124,12 @@ +
    HeaderWhen? Present Valid Value
    {name} + {#if check.importance} - + {check.importance} - + {/if} @@ -132,7 +143,7 @@ {/if} - {check.value || '-'} + {check.value || '-'} {#if check.issues && check.issues.length > 0} {#each check.issues as issue}
    diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte index 55fd7df..c739c5d 100644 --- a/web/src/lib/components/MxRecordsDisplay.svelte +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -1,49 +1,54 @@ -
    -
    - MX {title} -
    - {#if description} -

    {description}

    - {/if} -
    - - - - - - - - - - {#each mxRecords as mx} - - - - - - {/each} - -
    PriorityHostStatus
    {mx.priority}{mx.host} - {#if mx.valid} - Valid - {:else} - Invalid - {#if mx.error} -
    {mx.error} - {/if} - {/if} -
    +
    +
    +
    + + {title} +
    + MX +
    +
    + {#if description} +

    {description}

    + {/if} +
    +
    + {#each mxRecords as mx} +
    +
    + {#if mx.valid} + Valid + {:else} + Invalid + {/if} +
    Host: {mx.host}
    +
    Priority: {mx.priority}
    +
    + {#if mx.error} + {mx.error} + {/if} +
    + {/each}
    diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index 374a4f7..7a588f0 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -12,7 +12,7 @@ let { spamassassin, spamGrade, spamScore }: Props = $props(); -
    +

    From 5d335c6a6c06eb74c9352a7ff53006ecaccce21e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 11:42:27 +0700 Subject: [PATCH 069/256] Add email-path checks --- api/openapi.yaml | 8 + pkg/analyzer/headers.go | 119 ++++++ pkg/analyzer/headers_test.go | 340 ++++++++++++++++++ web/src/lib/components/BlacklistCard.svelte | 10 +- web/src/lib/components/EmailPathCard.svelte | 41 +++ .../lib/components/HeaderAnalysisCard.svelte | 29 -- web/src/routes/test/[test]/+page.svelte | 1 + 7 files changed, 517 insertions(+), 31 deletions(-) create mode 100644 web/src/lib/components/EmailPathCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 6012ba0..c0acfab 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -589,6 +589,14 @@ components: type: string format: date-time description: When this hop occurred + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" DomainAlignment: type: object diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 4364218..57973b1 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -23,7 +23,10 @@ package analyzer import ( "fmt" + "net" + "regexp" "strings" + "time" "git.happydns.org/happyDeliver/internal/api" ) @@ -209,6 +212,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header analysis.Headers = &headers + // Received chain + receivedChain := h.parseReceivedChain(email) + if len(receivedChain) > 0 { + analysis.ReceivedChain = &receivedChain + } + // Domain alignment domainAlignment := h.analyzeDomainAlignment(email) if domainAlignment != nil { @@ -356,3 +365,113 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue return issues } + +// parseReceivedChain extracts the chain of Received headers from an email +func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop { + if email == nil || email.Header == nil { + return nil + } + + receivedHeaders := email.Header["Received"] + if len(receivedHeaders) == 0 { + return nil + } + + var chain []api.ReceivedHop + + for _, receivedValue := range receivedHeaders { + hop := h.parseReceivedHeader(receivedValue) + if hop != nil { + chain = append(chain, *hop) + } + } + + return chain +} + +// parseReceivedHeader parses a single Received header value +func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop { + hop := &api.ReceivedHop{} + + // Normalize whitespace - Received headers can span multiple lines + normalized := strings.Join(strings.Fields(receivedValue), " ") + + // Extract "from" field + fromRegex := regexp.MustCompile(`(?i)from\s+([^\s(]+)`) + if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 { + from := matches[1] + hop.From = &from + } + + // Extract "by" field + byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`) + if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 { + by := matches[1] + hop.By = &by + } + + // Extract "with" field (protocol) - must come after "by" and before "id" or "for" + // This ensures we get the mail transfer protocol, not other "with" occurrences + withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)`) + if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 { + with := matches[1] + hop.With = &with + } + + // Extract "id" field + idRegex := regexp.MustCompile(`(?i)id\s+([^\s;]+)`) + if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 { + id := matches[1] + hop.Id = &id + } + + // Extract IP address from parentheses after "from" + // Pattern: from hostname (anything [IPv4/IPv6]) + ipRegex := regexp.MustCompile(`\[([^\]]+)\]`) + if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 { + ipStr := matches[1] + + // Handle IPv6: prefix (some MTAs include this) + ipStr = strings.TrimPrefix(ipStr, "IPv6:") + + // Check if it's a valid IP (IPv4 or IPv6) + if net.ParseIP(ipStr) != nil { + hop.Ip = &ipStr + + // Perform reverse DNS lookup + if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 { + // Remove trailing dot from PTR record + reverse := strings.TrimSuffix(reverseNames[0], ".") + hop.Reverse = &reverse + } + } + } + + // Extract timestamp - usually at the end after semicolon + // Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)" + timestampRegex := regexp.MustCompile(`;\s*(.+)$`) + if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 { + timestampStr := strings.TrimSpace(matches[1]) + + // Remove timezone name in parentheses if present + timestampStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(timestampStr, "") + + // Try parsing with common email date formats + formats := []string{ + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04:05 -0700", + } + + for _, format := range formats { + if parsedTime, err := time.Parse(format, timestampStr); err == nil { + hop.Timestamp = &parsedTime + break + } + } + } + + return hop +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 6840b0f..46b4a71 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -25,6 +25,8 @@ import ( "net/mail" "net/textproto" "testing" + + "git.happydns.org/happyDeliver/internal/api" ) func TestCalculateHeaderScore(t *testing.T) { @@ -395,3 +397,341 @@ func createHeaderWithFields(fields map[string]string) mail.Header { } return header } + +func TestParseReceivedChain(t *testing.T) { + tests := []struct { + name string + receivedHeaders []string + expectedHops int + validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop) + }{ + { + name: "No Received headers", + receivedHeaders: []string{}, + expectedHops: 0, + }, + { + name: "Single Received header", + receivedHeaders: []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "mail.example.com" { + t.Errorf("From = %v, want 'mail.example.com'", hop.From) + } + if hop.By == nil || *hop.By != "mx.receiver.com" { + t.Errorf("By = %v, want 'mx.receiver.com'", hop.By) + } + if hop.With == nil || *hop.With != "ESMTPS" { + t.Errorf("With = %v, want 'ESMTPS'", hop.With) + } + if hop.Id == nil || *hop.Id != "ABC123" { + t.Errorf("Id = %v, want 'ABC123'", hop.Id) + } + if hop.Ip == nil || *hop.Ip != "192.0.2.1" { + t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip) + } + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + }, + }, + { + name: "Multiple Received headers", + receivedHeaders: []string{ + "from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000", + "from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000", + }, + expectedHops: 2, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) != 2 { + t.Fatalf("Expected 2 hops, got %d", len(hops)) + } + + // Check first hop + if hops[0].From == nil || *hops[0].From != "mail1.example.com" { + t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From) + } + + // Check second hop + if hops[1].From == nil || *hops[1].From != "mail2.example.com" { + t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From) + } + }, + }, + { + name: "IPv6 address", + receivedHeaders: []string{ + "from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.Ip == nil { + t.Fatal("IP should not be nil for IPv6 address") + } + // Should strip the "IPv6:" prefix + if *hop.Ip != "2607:5300:203:2818::1" { + t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip) + } + }, + }, + { + name: "Multiline Received header", + receivedHeaders: []string{ + `from nemunai.re (unknown [IPv6:2607:5300:203:2818::1]) + (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) + key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) + (No client certificate requested) + (Authenticated sender: nemunaire) + by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA + for ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`, + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "nemunai.re" { + t.Errorf("From = %v, want 'nemunai.re'", hop.From) + } + if hop.By == nil || *hop.By != "djehouty.pomail.fr" { + t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By) + } + if hop.With == nil { + t.Error("With should not be nil") + } else if *hop.With != "ESMTPSA" { + t.Errorf("With = %q, want 'ESMTPSA'", *hop.With) + } + if hop.Id == nil || *hop.Id != "1EFD11611EA" { + t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id) + } + }, + }, + { + name: "Received header with minimal information", + receivedHeaders: []string{ + "from unknown by localhost", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) { + if len(hops) == 0 { + t.Fatal("Expected at least one hop") + } + hop := hops[0] + + if hop.From == nil || *hop.From != "unknown" { + t.Errorf("From = %v, want 'unknown'", hop.From) + } + if hop.By == nil || *hop.By != "localhost" { + t.Errorf("By = %v, want 'localhost'", hop.By) + } + }, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(mail.Header) + if len(tt.receivedHeaders) > 0 { + header["Received"] = tt.receivedHeaders + } + + email := &EmailMessage{ + Header: header, + } + + chain := analyzer.parseReceivedChain(email) + + if len(chain) != tt.expectedHops { + t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops) + } + + if tt.validateFirst != nil { + tt.validateFirst(t, email, chain) + } + }) + } +} + +func TestParseReceivedHeader(t *testing.T) { + tests := []struct { + name string + receivedValue string + expectFrom *string + expectBy *string + expectWith *string + expectId *string + expectIp *string + expectHasTs bool + }{ + { + name: "Complete Received header", + receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for ; Mon, 01 Jan 2024 12:00:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("ESMTPS"), + expectId: strPtr("ABC123"), + expectIp: strPtr("192.0.2.1"), + expectHasTs: true, + }, + { + name: "Minimal Received header", + receivedValue: "from sender.example.com by receiver.example.com", + expectFrom: strPtr("sender.example.com"), + expectBy: strPtr("receiver.example.com"), + expectWith: nil, + expectId: nil, + expectIp: nil, + expectHasTs: false, + }, + { + name: "Received header with ESMTPA", + receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500", + expectFrom: strPtr("[192.0.2.50]"), + expectBy: strPtr("mail.example.com"), + expectWith: strPtr("ESMTPA"), + expectId: strPtr("XYZ789"), + expectIp: strPtr("192.0.2.50"), + expectHasTs: true, + }, + { + name: "Received header without IP", + receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000", + expectFrom: strPtr("mail.example.com"), + expectBy: strPtr("mx.receiver.com"), + expectWith: strPtr("SMTP"), + expectId: nil, + expectIp: nil, + expectHasTs: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hop := analyzer.parseReceivedHeader(tt.receivedValue) + + if hop == nil { + t.Fatal("parseReceivedHeader returned nil") + } + + // Check From + if !equalStrPtr(hop.From, tt.expectFrom) { + t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom)) + } + + // Check By + if !equalStrPtr(hop.By, tt.expectBy) { + t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy)) + } + + // Check With + if !equalStrPtr(hop.With, tt.expectWith) { + t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith)) + } + + // Check Id + if !equalStrPtr(hop.Id, tt.expectId) { + t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId)) + } + + // Check Ip + if !equalStrPtr(hop.Ip, tt.expectIp) { + t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp)) + } + + // Check Timestamp + if tt.expectHasTs { + if hop.Timestamp == nil { + t.Error("Timestamp should not be nil") + } + } + }) + } +} + +func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { + analyzer := NewHeaderAnalyzer() + + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + } + + // Add Received headers + email.Header["Received"] = []string{ + "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000", + "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000", + } + + analysis := analyzer.GenerateHeaderAnalysis(email) + + if analysis == nil { + t.Fatal("GenerateHeaderAnalysis returned nil") + } + + if analysis.ReceivedChain == nil { + t.Fatal("ReceivedChain should not be nil") + } + + chain := *analysis.ReceivedChain + if len(chain) != 2 { + t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain)) + } + + // Check first hop + if chain[0].From == nil || *chain[0].From != "mail.example.com" { + t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From) + } + + // Check second hop + if chain[1].From == nil || *chain[1].From != "relay.example.com" { + t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From) + } +} + +// Helper functions for testing +func strPtr(s string) *string { + return &s +} + +func ptrToStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func equalStrPtr(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 1e5fd2a..a3dd010 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,15 +1,17 @@
    @@ -32,6 +34,10 @@

    + {#if receivedChain} + + {/if} +
    {#each Object.entries(blacklists) as [ip, checks]}
    diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte new file mode 100644 index 0000000..701feee --- /dev/null +++ b/web/src/lib/components/EmailPathCard.svelte @@ -0,0 +1,41 @@ + + +{#if receivedChain && receivedChain.length > 0} +
    +
    Email Path (Received Chain)
    +
    + {#each receivedChain as hop, i} +
    +
    +
    + {receivedChain.length - i} + {hop.reverse || '-'} ({hop.ip}) → {hop.by || 'Unknown'} +
    + {hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'} +
    + {#if hop.with || hop.id} +

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

    + {/if} +
    + {/each} +
    +
    +{/if} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 9a7857c..5979a66 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -160,34 +160,5 @@
    {/if} - - {#if headerAnalysis.received_chain && headerAnalysis.received_chain.length > 0} -
    -
    Email Path (Received Chain)
    -
    - {#each headerAnalysis.received_chain as hop, i} -
    -
    -
    - {i + 1} - {hop.from || 'Unknown'} → {hop.by || 'Unknown'} -
    - {hop.timestamp || '-'} -
    - {#if hop.with || hop.id} -

    - {#if hop.with} - Protocol: {hop.with} - {/if} - {#if hop.id} - ID: {hop.id} - {/if} -

    - {/if} -
    - {/each} -
    -
    - {/if}
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c80cd0b..112ff10 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -173,6 +173,7 @@ blacklists={report.blacklists} blacklistGrade={report.summary?.blacklist_grade} blacklistScore={report.summary?.blacklist_score} + receivedChain={report.header_analysis?.received_chain} />
    From e5c678174c027c9aa6281c1823a3b4e0b40339ba Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 12:31:20 +0700 Subject: [PATCH 070/256] Comprehensive DMARC record checks --- api/openapi.yaml | 21 ++ pkg/analyzer/dns.go | 136 +++++++++- pkg/analyzer/dns_test.go | 238 ++++++++++++++++++ .../lib/components/DmarcRecordDisplay.svelte | 211 ++++++++++++++++ web/src/lib/components/DnsRecordsCard.svelte | 49 +--- 5 files changed, 600 insertions(+), 55 deletions(-) create mode 100644 web/src/lib/components/DmarcRecordDisplay.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index c0acfab..23cf1b6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -914,6 +914,27 @@ components: enum: [none, quarantine, reject, unknown] description: DMARC policy example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" valid: type: boolean description: Whether the DMARC record is valid diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 9dc12fa..11a6e17 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -420,20 +420,38 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { // Extract policy policy := d.extractDMARCPolicy(dmarcRecord) + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + // Basic validation if !d.validateDMARC(dmarcRecord) { return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - Valid: false, - Error: api.PtrTo("DMARC record appears malformed"), + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: api.PtrTo("DMARC record appears malformed"), } } return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - Valid: true, + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, } } @@ -448,6 +466,71 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { return "unknown" } +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { + // Look for aspf=s (strict) or aspf=r (relaxed) + re := regexp.MustCompile(`aspf=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { + // Look for adkim=s (strict) or adkim=r (relaxed) + re := regexp.MustCompile(`adkim=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) +} + +// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record +// Returns the sp tag value or nil if not specified (defaults to main policy) +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { + // Look for sp=none, sp=quarantine, or sp=reject + re := regexp.MustCompile(`sp=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + // validateDMARC performs basic DMARC record validation func (d *DNSAnalyzer) validateDMARC(record string) bool { // Must start with v=DMARC1 @@ -657,7 +740,7 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // DMARC ties SPF and DKIM together and provides policy if results.DmarcRecord != nil { if results.DmarcRecord.Valid { - score += 15 + score += 10 // Bonus points for stricter policies if results.DmarcRecord.Policy != nil { switch *results.DmarcRecord.Policy { @@ -671,6 +754,43 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { score -= 5 } } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + score += 1 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + score += 1 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 3 + } else { + // Subdomain policy is weaker + score -= 3 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 3 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } } else if results.DmarcRecord.Record != nil { // Partial credit if DMARC record exists but has issues score += 5 diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 664ae5e..c397726 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -397,3 +397,241 @@ func TestValidateBIMI(t *testing.T) { }) } } + +func TestExtractDMARCSPFAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", + }, + { + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", + }, + { + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCDKIMAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", + }, + { + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: stringPtr("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: stringPtr("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: stringPtr("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: stringPtr("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + +func TestExtractDMARCPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: intPtr(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: intPtr(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: intPtr(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: intPtr(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: intPtr(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPercentage(tt.record) + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} + +// Helper functions for test pointers +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte new file mode 100644 index 0000000..30cddeb --- /dev/null +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -0,0 +1,211 @@ + + +{#if dmarcRecord} +
    +
    +
    + + Domain-based Message Authentication +
    + DMARC +
    +
    +

    DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

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

    DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

    -
    - Status: - {#if dnsResults.dmarc_record.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if dnsResults.dmarc_record.policy} -
    - Policy: - - {dnsResults.dmarc_record.policy} - -
    - {/if} - {#if dnsResults.dmarc_record.record} -
    - Record:
    - {dnsResults.dmarc_record.record} -
    - {/if} - {#if dnsResults.dmarc_record.error} -
    - Error: {dnsResults.dmarc_record.error} -
    - {/if} -
    -
    - {/if} + {#if dnsResults.bimi_record} From a6448a1533bb1523abc75a36daf9bbca9fa3b577 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 12:36:28 +0700 Subject: [PATCH 071/256] Split DnsRecordsCard in several components --- .../lib/components/BimiRecordDisplay.svelte | 77 ++++++++ .../lib/components/DkimRecordsDisplay.svelte | 64 +++++++ .../lib/components/DmarcRecordDisplay.svelte | 127 ++++++++++---- web/src/lib/components/DnsRecordsCard.svelte | 166 +----------------- .../lib/components/MxRecordsDisplay.svelte | 2 +- .../lib/components/SpfRecordsDisplay.svelte | 69 ++++++++ 6 files changed, 310 insertions(+), 195 deletions(-) create mode 100644 web/src/lib/components/BimiRecordDisplay.svelte create mode 100644 web/src/lib/components/DkimRecordsDisplay.svelte create mode 100644 web/src/lib/components/SpfRecordsDisplay.svelte diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte new file mode 100644 index 0000000..0d7a1b9 --- /dev/null +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -0,0 +1,77 @@ + + +{#if bimiRecord} +
    +
    +
    + + Brand Indicators for Message Identification +
    + BIMI +
    +
    +

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

    + +
    + +
    + Selector: {bimiRecord.selector} + Domain: {bimiRecord.domain} +
    +
    + Status: + {#if bimiRecord.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if bimiRecord.logo_url} +
    + Logo URL: + {bimiRecord.logo_url} +
    + {/if} + {#if bimiRecord.vmc_url} +
    + VMC URL: + {bimiRecord.vmc_url} +
    + {/if} + {#if bimiRecord.record} +
    + Record:
    + {bimiRecord.record} +
    + {/if} + {#if bimiRecord.error} +
    + Error: + {bimiRecord.error} +
    + {/if} +
    +
    +{/if} diff --git a/web/src/lib/components/DkimRecordsDisplay.svelte b/web/src/lib/components/DkimRecordsDisplay.svelte new file mode 100644 index 0000000..45c67b3 --- /dev/null +++ b/web/src/lib/components/DkimRecordsDisplay.svelte @@ -0,0 +1,64 @@ + + +{#if dkimRecords && dkimRecords.length > 0} +
    +
    +
    + + DomainKeys Identified Mail +
    + DKIM +
    +
    +

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

    +
    +
    + {#each dkimRecords as dkim} +
    +
    + Selector: {dkim.selector} + Domain: {dkim.domain} +
    +
    + Status: + {#if dkim.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if dkim.record} +
    + Record:
    + {dkim.record} +
    + {/if} + {#if dkim.error} +
    + Error: {dkim.error} +
    + {/if} +
    + {/each} +
    +
    +{/if} diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 30cddeb..4d4cad3 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -10,7 +10,7 @@ // Helper function to determine policy strength const policyStrength = (policy: string | undefined): number => { const strength: Record = { none: 0, quarantine: 1, reject: 2 }; - return strength[policy || 'none'] || 0; + return strength[policy || "none"] || 0; }; @@ -22,7 +22,8 @@ class="bi" class:bi-check-circle-fill={dmarcRecord.valid && dmarcRecord.policy != "none"} class:text-success={dmarcRecord.valid && dmarcRecord.policy != "none"} - class:bi-arrow-up-circle-fill={dmarcRecord.valid && dmarcRecord.policy == "none"} + class:bi-arrow-up-circle-fill={dmarcRecord.valid && + dmarcRecord.policy == "none"} class:text-warning={dmarcRecord.valid && dmarcRecord.policy == "none"} class:bi-x-circle-fill={!dmarcRecord.valid} class:text-danger={!dmarcRecord.valid} @@ -32,7 +33,13 @@ DMARC
    -

    DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

    +

    + DMARC builds on SPF and DKIM by telling receiving servers what to do with emails + that fail authentication checks. It also enables reporting so you can monitor your + email security. +

    + +
    @@ -48,32 +55,44 @@ {#if dmarcRecord.policy}
    Policy: - + {dmarcRecord.policy} - {#if dmarcRecord.policy === 'reject'} + {#if dmarcRecord.policy === "reject"}
    - Maximum protection — emails failing DMARC checks are rejected. This provides the strongest defense against spoofing and phishing. + Maximum protection — emails failing DMARC checks are rejected. + This provides the strongest defense against spoofing and phishing.
    - {:else if dmarcRecord.policy === 'quarantine'} + {:else if dmarcRecord.policy === "quarantine"}
    - Good protection — emails failing DMARC checks are quarantined (sent to spam). This is a safe middle ground.
    + Good protection — emails failing DMARC checks are + quarantined (sent to spam). This is a safe middle ground.
    - Once you've validated your configuration and ensured all legitimate mail passes, consider upgrading to p=reject for maximum protection. + Once you've validated your configuration and ensured all legitimate mail + passes, consider upgrading to p=reject for maximum protection.
    - {:else if dmarcRecord.policy === 'none'} + {:else if dmarcRecord.policy === "none"}
    - Monitoring only — emails failing DMARC are delivered normally. This is only recommended during initial setup.
    + Monitoring only — emails failing DMARC are delivered + normally. This is only recommended during initial setup.
    - After monitoring reports, upgrade to p=quarantine or p=reject to actively protect your domain. + After monitoring reports, upgrade to p=quarantine or + p=reject to actively protect your domain.
    {:else}
    - Unknown policy — the policy value is not recognized. Valid options are: none, quarantine, or reject. + Unknown policy — the policy value is not recognized. Valid + options are: none, quarantine, or reject.
    {/if}
    @@ -85,18 +104,27 @@ {@const subStrength = policyStrength(dmarcRecord.subdomain_policy)}
    Subdomain Policy: - + {dmarcRecord.subdomain_policy} {#if subStrength >= mainStrength}
    - Good configuration — subdomain policy is equal to or stricter than main policy. + Good configuration — subdomain policy is equal to or stricter + than main policy.
    {:else}
    - Weaker subdomain protection — consider setting sp={dmarcRecord.policy} to match your main policy for consistent protection. + Weaker subdomain protection — consider setting + sp={dmarcRecord.policy} to match your main policy for consistent + protection.
    {/if}
    @@ -106,7 +134,9 @@ Inherits main policy
    - Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection. + Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection.
    {/if} @@ -115,23 +145,34 @@ {#if dmarcRecord.percentage !== undefined}
    Enforcement Percentage: - + {dmarcRecord.percentage}% {#if dmarcRecord.percentage === 100}
    - Full enforcement — all messages are subject to DMARC policy. This provides maximum protection. + Full enforcement — all messages are subject to DMARC policy. + This provides maximum protection.
    {:else if dmarcRecord.percentage >= 50}
    - Partial enforcement — only {dmarcRecord.percentage}% of messages are subject to DMARC policy. Consider increasing to pct=100 once you've validated your configuration. + Partial enforcement — only {dmarcRecord.percentage}% of + messages are subject to DMARC policy. Consider increasing to + pct=100 once you've validated your configuration.
    {:else}
    - Low enforcement — only {dmarcRecord.percentage}% of messages are protected. Gradually increase to pct=100 for full protection. + Low enforcement — only {dmarcRecord.percentage}% of + messages are protected. Gradually increase to pct=100 for full + protection.
    {/if}
    @@ -141,7 +182,8 @@ 100% (default)
    - Full enforcement — all messages are subject to DMARC policy by default. + Full enforcement — all messages are subject to DMARC policy + by default.
    {/if} @@ -150,20 +192,28 @@ {#if dmarcRecord.spf_alignment}
    SPF Alignment: - + {dmarcRecord.spf_alignment} - {#if dmarcRecord.spf_alignment === 'relaxed'} + {#if dmarcRecord.spf_alignment === "relaxed"}
    - Recommended for most senders — ensures legitimate subdomain mail passes.
    + Recommended for most senders — ensures legitimate + subdomain mail passes.
    - For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized. + For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized.
    {:else}
    - Maximum brand protection — only exact domain matches are accepted. Ensure all legitimate mail comes from the exact From domain. + Maximum brand protection — only exact domain matches are + accepted. Ensure all legitimate mail comes from the exact From domain.
    {/if}
    @@ -173,20 +223,28 @@ {#if dmarcRecord.dkim_alignment}
    DKIM Alignment: - + {dmarcRecord.dkim_alignment} - {#if dmarcRecord.dkim_alignment === 'relaxed'} + {#if dmarcRecord.dkim_alignment === "relaxed"}
    - Recommended for most senders — ensures legitimate subdomain mail passes.
    + Recommended for most senders — ensures legitimate + subdomain mail passes.
    - For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized. + For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized.
    {:else}
    - Maximum brand protection — only exact domain matches are accepted. Ensure all DKIM signatures use the exact From domain. + Maximum brand protection — only exact domain matches are + accepted. Ensure all DKIM signatures use the exact From domain.
    {/if}
    @@ -195,7 +253,7 @@ {#if dmarcRecord.record}
    - Record:
    + Record:
    {dmarcRecord.record}
    {/if} @@ -203,7 +261,8 @@ {#if dmarcRecord.error}
    - Error: {dmarcRecord.error} + Error: + {dmarcRecord.error}
    {/if}
    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 4984f61..a1ee24d 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -3,7 +3,10 @@ import { getScoreColorClass } from "$lib/score"; import GradeDisplay from "./GradeDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; + import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; + import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte"; import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte"; + import BimiRecordDisplay from "./BimiRecordDisplay.svelte"; interface Props { dnsResults?: DNSResults; @@ -75,61 +78,7 @@ {/if} - {#if dnsResults.spf_records && dnsResults.spf_records.length > 0} - {@const spfIsValid = dnsResults.spf_records.reduce((acc, r) => acc && r.valid, true)} -
    -
    -
    - - Sender Policy Framework -
    - SPF -
    -
    -

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

    -
    -
    - {#each dnsResults.spf_records as spf, index} -
    - {#if spf.domain} -
    - Domain: {spf.domain} - {#if index > 0} - Included - {/if} -
    - {/if} -
    - Status: - {#if spf.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if spf.record} -
    - Record:
    - {spf.record} -
    - {/if} - {#if spf.error} -
    - - {spf.valid ? 'Warning:' : 'Error:'} {spf.error} -
    - {/if} -
    - {/each} -
    -
    - {/if} +
    @@ -154,116 +103,13 @@ {/if} - {#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0} - {@const dkimIsValid = dnsResults.dkim_records.reduce((acc, r) => acc && r.valid, true)} -
    -
    -
    - - DomainKeys Identified Mail -
    - DKIM -
    -
    -

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

    -
    -
    - {#each dnsResults.dkim_records as dkim} -
    -
    - Selector: {dkim.selector} - Domain: {dkim.domain} -
    -
    - Status: - {#if dkim.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if dkim.record} -
    - Record:
    - {dkim.record} -
    - {/if} - {#if dkim.error} -
    - Error: {dkim.error} -
    - {/if} -
    - {/each} -
    -
    - {/if} + - {#if dnsResults.bimi_record} -
    -
    -
    - - Brand Indicators for Message Identification -
    - BIMI -
    -
    -
    -

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

    -
    - Selector: {dnsResults.bimi_record.selector} - Domain: {dnsResults.bimi_record.domain} -
    -
    - Status: - {#if dnsResults.bimi_record.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if dnsResults.bimi_record.logo_url} - - {/if} - {#if dnsResults.bimi_record.vmc_url} - - {/if} - {#if dnsResults.bimi_record.record} -
    - Record:
    - {dnsResults.bimi_record.record} -
    - {/if} - {#if dnsResults.bimi_record.error} -
    - Error: {dnsResults.bimi_record.error} -
    - {/if} -
    -
    -
    - {/if} + {/if} diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte index c739c5d..f0a8088 100644 --- a/web/src/lib/components/MxRecordsDisplay.svelte +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -28,7 +28,7 @@ MX -
    +
    {#if description}

    {description}

    {/if} diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte new file mode 100644 index 0000000..172e9f7 --- /dev/null +++ b/web/src/lib/components/SpfRecordsDisplay.svelte @@ -0,0 +1,69 @@ + + +{#if spfRecords && spfRecords.length > 0} +
    +
    +
    + + Sender Policy Framework +
    + SPF +
    +
    +

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

    +
    +
    + {#each spfRecords as spf, index} +
    + {#if spf.domain} +
    + Domain: {spf.domain} + {#if index > 0} + Included + {/if} +
    + {/if} +
    + Status: + {#if spf.valid} + Valid + {:else} + Invalid + {/if} +
    + {#if spf.record} +
    + Record:
    + {spf.record} +
    + {/if} + {#if spf.error} +
    + + {spf.valid ? 'Warning:' : 'Error:'} {spf.error} +
    + {/if} +
    + {/each} +
    +
    +{/if} From 326abc074496ce8268b1a86d9fc208bbf59b5870 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 13:27:29 +0700 Subject: [PATCH 072/256] Detect SPF all mechanism --- api/openapi.yaml | 5 ++ pkg/analyzer/dns.go | 60 ++++++++++--------- .../lib/components/SpfRecordsDisplay.svelte | 27 +++++++++ 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 23cf1b6..8dd1376 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -867,6 +867,11 @@ components: type: boolean description: Whether the SPF record is valid example: true + all_qualifier: + type: string + enum: ["+", "-", "~", "?"] + description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" + example: "~" error: type: string description: Error message if validation failed diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 11a6e17..54b0d2f 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -245,28 +245,34 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, // Basic validation valid := d.validateSPF(spfRecord) - // Check for strict -all mechanism + // Extract the "all" mechanism qualifier + var allQualifier *api.SPFRecordAllQualifier var errMsg *string + if !valid { errMsg = api.PtrTo("SPF record appears malformed") - } else if !d.hasSPFStrictFail(spfRecord) { - // Check what mechanism is used - if strings.HasSuffix(spfRecord, " ~all") { - errMsg = api.PtrTo("SPF uses ~all (softfail) instead of -all (hardfail). This weakens email authentication and may reduce deliverability.") - } else if strings.HasSuffix(spfRecord, " +all") || strings.HasSuffix(spfRecord, " ?all") { - errMsg = api.PtrTo("SPF uses permissive 'all' mechanism. This severely weakens email authentication. Use -all for strict policy.") + } else { + // Extract qualifier from the "all" mechanism + if strings.HasSuffix(spfRecord, " -all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) + } else if strings.HasSuffix(spfRecord, " ~all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) + } else if strings.HasSuffix(spfRecord, " +all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } else if strings.HasSuffix(spfRecord, " ?all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) } else if strings.HasSuffix(spfRecord, " all") { - errMsg = api.PtrTo("SPF uses neutral 'all' mechanism. Use -all for strict policy to improve deliverability.") - } else { - errMsg = api.PtrTo("SPF record should end with -all for strict policy to improve deliverability and prevent spoofing.") + // Implicit + qualifier (default) + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) } } results = append(results, api.SPFRecord{ - Domain: &domain, - Record: &spfRecord, - Valid: valid, - Error: errMsg, + Domain: &domain, + Record: &spfRecord, + Valid: valid, + AllQualifier: allQualifier, + Error: errMsg, }) // Extract and resolve include: directives @@ -694,23 +700,23 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { mainSPF := (*results.SpfRecords)[0] if mainSPF.Valid { // Full points for valid SPF - score += 20 + score += 15 - // Check for strict -all mechanism - if mainSPF.Record != nil && !d.hasSPFStrictFail(*mainSPF.Record) { - // Deduct points for weak SPF policy - if strings.HasSuffix(*mainSPF.Record, " ~all") { + // Deduct points based on the all mechanism qualifier + if mainSPF.AllQualifier != nil { + switch *mainSPF.AllQualifier { + case "-": + // Strict fail - no deduction, this is the recommended policy + score += 5 + case "~": // Softfail - moderate penalty - score -= 5 - } else if strings.HasSuffix(*mainSPF.Record, " +all") || - strings.HasSuffix(*mainSPF.Record, " ?all") || - strings.HasSuffix(*mainSPF.Record, " all") { + case "+", "?": // Pass/neutral - severe penalty - score -= 10 - } else { - // No 'all' mechanism at all - severe penalty - score -= 10 + score -= 5 } + } else { + // No 'all' mechanism qualifier extracted - severe penalty + score -= 5 } } else if mainSPF.Record != nil { // Partial credit if SPF record exists but has issues diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte index 172e9f7..e1086f7 100644 --- a/web/src/lib/components/SpfRecordsDisplay.svelte +++ b/web/src/lib/components/SpfRecordsDisplay.svelte @@ -50,6 +50,33 @@ Invalid {/if}
    + {#if spf.all_qualifier} +
    + All Mechanism Policy: + {#if spf.all_qualifier === '-'} + Strict (-all) + {:else if spf.all_qualifier === '~'} + Softfail (~all) + {:else if spf.all_qualifier === '+'} + Pass (+all) + {:else if spf.all_qualifier === '?'} + Neutral (?all) + {/if} + {#if index === 0} +
    + {#if spf.all_qualifier === '-'} + All unauthorized servers will be rejected. This is the recommended strict policy. + {:else if spf.all_qualifier === '~'} + Unauthorized servers will softfail. Consider using -all for stricter policy, though this rarely affects legitimate email deliverability. + {:else if spf.all_qualifier === '+'} + All servers are allowed to send email. This severely weakens email authentication. Use -all for strict policy. + {:else if spf.all_qualifier === '?'} + No statement about unauthorized servers. Use -all for strict policy to prevent spoofing. + {/if} +
    + {/if} +
    + {/if} {#if spf.record}
    Record:
    From 7bc7e7b7a27962976490306822aefb7ea88acb07 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 15:12:19 +0700 Subject: [PATCH 073/256] Reuse domain extractes from headers --- pkg/analyzer/dns.go | 30 +++------------------ pkg/analyzer/dns_test.go | 56 ---------------------------------------- pkg/analyzer/report.go | 4 +-- 3 files changed, 6 insertions(+), 84 deletions(-) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 54b0d2f..ee4d7d3 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -52,18 +52,18 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { // Extract domain from From address - fromDomain := d.extractFromDomain(email) - if fromDomain == "" { + if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { return &api.DNSResults{ Errors: &[]string{"Unable to extract domain from email"}, } } + fromDomain := *headersResults.DomainAlignment.FromDomain results := &api.DNSResults{ FromDomain: fromDomain, - RpDomain: d.extractRPDomain(email), + RpDomain: headersResults.DomainAlignment.ReturnPathDomain, } // Determine which domain to check SPF for (Return-Path domain) @@ -112,28 +112,6 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic return results } -// extractFromDomain extracts the domain from the email's From address -func (d *DNSAnalyzer) extractFromDomain(email *EmailMessage) string { - if email.From != nil && email.From.Address != "" { - parts := strings.Split(email.From.Address, "@") - if len(parts) == 2 { - return strings.ToLower(strings.TrimSpace(parts[1])) - } - } - return "" -} - -// extractRPDomain extracts the domain from the email's Return-Path address -func (d *DNSAnalyzer) extractRPDomain(email *EmailMessage) *string { - if email.ReturnPath != "" { - parts := strings.Split(email.ReturnPath, "@") - if len(parts) == 2 { - return api.PtrTo(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(parts[1])), ">")) - } - } - return nil -} - // checkMXRecords looks up MX records for a domain func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index c397726..d3deb20 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -22,7 +22,6 @@ package analyzer import ( - "net/mail" "testing" "time" ) @@ -57,61 +56,6 @@ func TestNewDNSAnalyzer(t *testing.T) { }) } } - -func TestExtractDomain(t *testing.T) { - tests := []struct { - name string - fromAddress string - expectedDomain string - }{ - { - name: "Valid email", - fromAddress: "user@example.com", - expectedDomain: "example.com", - }, - { - name: "Email with subdomain", - fromAddress: "user@mail.example.com", - expectedDomain: "mail.example.com", - }, - { - name: "Email with uppercase", - fromAddress: "User@Example.COM", - expectedDomain: "example.com", - }, - { - name: "Invalid email (no @)", - fromAddress: "invalid-email", - expectedDomain: "", - }, - { - name: "Empty email", - fromAddress: "", - expectedDomain: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - } - if tt.fromAddress != "" { - email.From = &mail.Address{ - Address: tt.fromAddress, - } - } - - domain := analyzer.extractFromDomain(email) - if domain != tt.expectedDomain { - t.Errorf("extractFromDomain() = %q, want %q", domain, tt.expectedDomain) - } - }) - } -} - func TestValidateSPF(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index b5bab60..6848a7d 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -74,11 +74,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.Content = r.contentAnalyzer.AnalyzeContent(email) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) + results.Content = r.contentAnalyzer.AnalyzeContent(email) return results } From 84a504d6685b028e849f47ea497c90d20af6c880 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 15:59:57 +0700 Subject: [PATCH 074/256] Add reverse lookup and forward confirmation --- api/openapi.yaml | 12 ++ pkg/analyzer/dns.go | 82 +++++++++++++- pkg/analyzer/report.go | 11 +- web/src/lib/components/DnsRecordsCard.svelte | 33 +++++- .../PtrForwardRecordsDisplay.svelte | 103 ++++++++++++++++++ .../lib/components/PtrRecordsDisplay.svelte | 85 +++++++++++++++ web/src/lib/components/index.ts | 2 + web/src/routes/test/[test]/+page.svelte | 1 + 8 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/PtrForwardRecordsDisplay.svelte create mode 100644 web/src/lib/components/PtrRecordsDisplay.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 8dd1376..ce39bdd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -819,6 +819,18 @@ components: $ref: '#/components/schemas/DMARCRecord' bimi_record: $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] errors: type: array items: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index ee4d7d3..542d704 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -73,6 +73,22 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic spfDomain = *results.RpDomain } + // Store sender IP for later use in scoring + var senderIP string + if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 { + firstHop := (*headersResults.ReceivedChain)[0] + if firstHop.Ip != nil && *firstHop.Ip != "" { + senderIP = *firstHop.Ip + ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP) + if len(ptrRecords) > 0 { + results.PtrRecords = &ptrRecords + } + if len(forwardRecords) > 0 { + results.PtrForwardRecords = &forwardRecords + } + } + } + // Check MX records for From domain (where replies would go) results.FromMxRecords = d.checkMXRecords(fromDomain) @@ -613,16 +629,78 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool { return true } +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better -func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { +// senderIP is the original sender IP address used for FCrDNS verification +func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) { if results == nil { return 0, "" } score := 0 - // TODO: 20 points for correct PTR and A/AAAA + // PTR and Forward DNS: 20 points + // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 10 points for having PTR records + score += 10 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 3 + } + + // Additional 10 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 10 + } + } + } // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) // Having valid MX records is critical for email deliverability diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6848a7d..bd6b866 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -98,7 +98,15 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu dnsScore := 0 var dnsGrade string if results.DNS != nil { - dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS) + // Extract sender IP from received chain for FCrDNS verification + var senderIP string + if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 { + firstHop := (*results.Headers.ReceivedChain)[0] + if firstHop.Ip != nil { + senderIP = *firstHop.Ip + } + } + dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP) } authScore := 0 @@ -178,6 +186,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu // Calculate overall score as mean of all category scores categoryScores := []int{ + report.Summary.DnsScore, report.Summary.AuthenticationScore, report.Summary.BlacklistScore, report.Summary.ContentScore, diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index a1ee24d..647a1d2 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,5 +1,5 @@
    @@ -51,6 +59,27 @@
    {/if} + + {#if receivedChain && receivedChain.length > 0} +
    +

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

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

    diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte new file mode 100644 index 0000000..77ce6c8 --- /dev/null +++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte @@ -0,0 +1,103 @@ + + +{#if ptrRecords && ptrRecords.length > 0} +
    +
    +
    + + Forward-Confirmed Reverse DNS +
    + FCrDNS +
    +
    +

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

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

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

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

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

    +
    + Sender IP: {senderIp} +
    +
    + + Error: No PTR records found for the sender IP. Contact your email service + provider to configure reverse DNS. +
    +
    +
    +{/if} diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index a5b56ae..d3b7909 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -10,3 +10,5 @@ export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; export { default as BlacklistCard } from "./BlacklistCard.svelte"; export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; +export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; +export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 112ff10..5731485 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -146,6 +146,7 @@ dnsResults={report.dns_results} dnsGrade={report.summary?.dns_grade} dnsScore={report.summary?.dns_score} + receivedChain={report.header_analysis?.received_chain} />

    From 3d03bfc4fa02ea2ff52ccce0d2e2407a4a07a57d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 16:44:50 +0700 Subject: [PATCH 075/256] Handle relaxed domain match --- api/openapi.yaml | 14 +++- pkg/analyzer/headers.go | 68 +++++++++++++++++-- web/src/lib/components/DnsRecordsCard.svelte | 35 +++++----- .../lib/components/HeaderAnalysisCard.svelte | 53 ++++++++++++--- web/src/routes/test/[test]/+page.svelte | 2 + 5 files changed, 137 insertions(+), 35 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index ce39bdd..88532b3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -605,10 +605,18 @@ components: type: string description: Domain from From header example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" return_path_domain: type: string description: Domain from Return-Path header example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" dkim_domains: type: array items: @@ -617,7 +625,11 @@ components: example: ["example.com"] aligned: type: boolean - description: Whether all domains align + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) example: true issues: type: array diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 57973b1..954f229 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -28,6 +28,8 @@ import ( "strings" "time" + "golang.org/x/net/publicsuffix" + "git.happydns.org/happyDeliver/internal/api" ) @@ -52,6 +54,8 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int // RP and From alignment (20 points) if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { score += 20 + } else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned { + score += 15 } else { maxGrade -= 2 } @@ -280,7 +284,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp // analyzeDomainAlignment checks domain alignment between headers func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { alignment := &api.DomainAlignment{ - Aligned: api.PtrTo(true), + Aligned: api.PtrTo(true), + RelaxedAligned: api.PtrTo(true), } // Extract From domain @@ -289,6 +294,9 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain domain := h.extractDomain(fromAddr) if domain != "" { alignment.FromDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.FromOrgDomain = &orgDomain } } @@ -298,15 +306,40 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain domain := h.extractDomain(returnPath) if domain != "" { alignment.ReturnPathDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.ReturnPathOrgDomain = &orgDomain } } - // Check alignment + // Check alignment (strict and relaxed) issues := []string{} if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { - if *alignment.FromDomain != *alignment.ReturnPathDomain { - *alignment.Aligned = false - issues = append(issues, "Return-Path domain does not match From domain") + fromDomain := *alignment.FromDomain + rpDomain := *alignment.ReturnPathDomain + + // Strict alignment: exact match (case-insensitive) + strictAligned := strings.EqualFold(fromDomain, rpDomain) + + // Relaxed alignment: organizational domain match + var fromOrgDomain, rpOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + if alignment.ReturnPathOrgDomain != nil { + rpOrgDomain = *alignment.ReturnPathOrgDomain + } + relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + + if !strictAligned { + if relaxedAligned { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain)) + } else { + issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) + } } } @@ -335,6 +368,27 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { return domain } +// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name +// using the Public Suffix List (PSL) to correctly handle multi-level TLDs. +// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk +func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + + // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) + // This correctly handles cases like .co.uk, .com.au, etc. + etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + // Fallback to simple two-label extraction if PSL lookup fails + labels := strings.Split(domain, ".") + if len(labels) <= 2 { + return domain + } + return strings.Join(labels[len(labels)-2:], ".") + } + + return etldPlusOne +} + // findHeaderIssues identifies issues with headers func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { var issues []api.HeaderIssue @@ -458,8 +512,8 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // Try parsing with common email date formats formats := []string{ - time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" - time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 -0700", diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 647a1d2..03b992b 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,5 +1,5 @@
    @@ -59,7 +60,7 @@
    - + Domain Alignment
    @@ -68,34 +69,64 @@ Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers.

    -
    - Aligned +
    + Strict Alignment
    - {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} + {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}
    +
    Exact domain match
    -
    +
    + Relaxed Alignment +
    + + + {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} + +
    +
    Organizational domain match
    +
    +
    From Domain
    {headerAnalysis.domain_alignment.from_domain || '-'}
    + {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    + {/if}
    -
    +
    Return-Path Domain
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    + {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
    Org: {headerAnalysis.domain_alignment.return_path_org_domain}
    + {/if}
    {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
    +
    {#each headerAnalysis.domain_alignment.issues as issue} -
    - +
    + {issue}
    {/each}
    {/if} + + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
    + {#if dmarcRecord.spf_alignment === 'strict'} + + Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. + {/if} +
    + {/if}
    {/if} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 5731485..28a140a 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -143,6 +143,7 @@
    Date: Thu, 23 Oct 2025 17:03:55 +0700 Subject: [PATCH 076/256] Add iprev check --- api/openapi.yaml | 25 +++ pkg/analyzer/authentication.go | 70 ++++++- pkg/analyzer/authentication_test.go | 197 ++++++++++++++++++ .../lib/components/AuthenticationCard.svelte | 30 +++ 4 files changed, 311 insertions(+), 11 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 88532b3..5484f9e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -678,6 +678,8 @@ components: $ref: '#/components/schemas/AuthResult' arc: $ref: '#/components/schemas/ARCResult' + iprev: + $ref: '#/components/schemas/IPRevResult' AuthResult: type: object @@ -724,6 +726,29 @@ components: description: Additional details about ARC validation example: "ARC chain valid with 2 intermediaries" + IPRevResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, temperror, permerror] + description: IP reverse DNS lookup result + example: "pass" + ip: + type: string + description: IP address that was checked + example: "195.110.101.58" + hostname: + type: string + description: Hostname from reverse DNS lookup (PTR record) + example: "authsmtp74.register.it" + details: + type: string + description: Additional details about the IP reverse lookup + example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + SpamAssassinResult: type: object required: diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index ef0c400..84bbb9e 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -127,6 +127,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Arc = a.parseARCResult(part) } } + + // Parse IPRev + if strings.HasPrefix(part, "iprev=") { + if results.Iprev == nil { + results.Iprev = a.parseIPRevResult(part) + } + } } } @@ -261,6 +268,37 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { return result } +// parseIPRevResult parses IP reverse lookup result from Authentication-Results +// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { + result := &api.IPRevResult{} + + // Extract result (pass, fail, temperror, permerror, none) + re := regexp.MustCompile(`iprev=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.IPRevResultResult(resultStr) + } + + // Extract IP address (smtp.remote-ip or remote-ip) + ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) + if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { + ip := matches[1] + result.Ip = &ip + } + + // Extract hostname from parentheses + hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) + if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { + hostname := matches[1] + result.Hostname = &hostname + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + + return result +} + // 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 { @@ -470,21 +508,31 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe score := 0 - // SPF (30 points) - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 30 - case api.AuthResultResultNeutral, api.AuthResultResultNone: + // IPRev (15 points) + if results.Iprev != nil { + switch results.Iprev.Result { + case api.Pass: score += 15 - case api.AuthResultResultSoftfail: - score += 5 default: // fail, temperror, permerror score += 0 } } - // DKIM (30 points) - at least one passing signature + // SPF (25 points) + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 25 + case api.AuthResultResultNeutral, api.AuthResultResultNone: + score += 12 + case api.AuthResultResultSoftfail: + score += 4 + default: // fail, temperror, permerror + score += 0 + } + } + + // DKIM (20 points) - at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { hasPass := false for _, dkim := range *results.Dkim { @@ -494,10 +542,10 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe } } if hasPass { - score += 30 + score += 20 } else { // Has DKIM signatures but none passed - score += 10 + score += 7 } } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index d0c4f18..8535ff2 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -1149,3 +1149,200 @@ func TestParseLegacyDKIM_Integration(t *testing.T) { } }) } + +func TestParseIPRevResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass with IP and hostname", + part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev pass without smtp prefix", + part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", + expectedResult: api.Fail, + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: api.PtrTo("unknown.host.com"), + }, + { + name: "IPRev temperror", + part: "iprev=temperror smtp.remote-ip=203.0.113.1", + expectedResult: api.Temperror, + expectedIP: api.PtrTo("203.0.113.1"), + expectedHostname: nil, + }, + { + name: "IPRev permerror", + part: "iprev=permerror smtp.remote-ip=192.0.2.100", + expectedResult: api.Permerror, + expectedIP: api.PtrTo("192.0.2.100"), + expectedHostname: nil, + }, + { + name: "IPRev with IPv6", + part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("2001:db8::1"), + expectedHostname: api.PtrTo("ipv6.example.com"), + }, + { + name: "IPRev with subdomain hostname", + part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.50"), + expectedHostname: api.PtrTo("mail.subdomain.example.com"), + }, + { + name: "IPRev pass without parentheses", + part: "iprev=pass smtp.remote-ip=192.0.2.200", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.200"), + expectedHostname: nil, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseIPRevResult(tt.part) + + // Check result + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + // Check IP + if tt.expectedIP != nil { + if result.Ip == nil { + t.Errorf("IP = nil, want %v", *tt.expectedIP) + } else if *result.Ip != *tt.expectedIP { + t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) + } + } else { + if result.Ip != nil { + t.Errorf("IP = %v, want nil", *result.Ip) + } + } + + // Check hostname + if tt.expectedHostname != nil { + if result.Hostname == nil { + t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) + } else if *result.Hostname != *tt.expectedHostname { + t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) + } + } else { + if result.Hostname != nil { + t.Errorf("Hostname = %v, want nil", *result.Hostname) + } + } + + // Check details + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } + }) + } +} + +func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { + tests := []struct { + name string + header string + expectedIPRevResult *api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass in Authentication-Results", + header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev with other authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", + expectedIPRevResult: api.PtrTo(api.Fail), + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: nil, + }, + { + name: "No IPRev in header", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", + expectedIPRevResult: nil, + }, + { + name: "Multiple IPRev results - only first is parsed", + header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("first.com"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check IPRev + if tt.expectedIPRevResult != nil { + if results.Iprev == nil { + t.Errorf("Expected IPRev result, got nil") + } else { + if results.Iprev.Result != *tt.expectedIPRevResult { + t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) + } + if tt.expectedIP != nil { + if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { + var gotIP string + if results.Iprev.Ip != nil { + gotIP = *results.Iprev.Ip + } + t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) + } + } + if tt.expectedHostname != nil { + if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { + var gotHostname string + if results.Iprev.Hostname != nil { + gotHostname = *results.Iprev.Hostname + } + t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) + } + } + } + } else { + if results.Iprev != nil { + t.Errorf("Expected no IPRev result, got %+v", results.Iprev) + } + } + }) + } +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index b44c102..b1a7864 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -73,6 +73,36 @@
    + + {#if authentication.iprev} +
    +
    + +
    + IP Reverse DNS + + {authentication.iprev.result} + + {#if authentication.iprev.ip} +
    + IP Address: + {authentication.iprev.ip} +
    + {/if} + {#if authentication.iprev.hostname} +
    + Hostname: + {authentication.iprev.hostname} +
    + {/if} + {#if authentication.iprev.details} +
    {authentication.iprev.details}
    + {/if} +
    +
    +
    + {/if} +
    From 3588af3267304b40c2094cd38386646ac99dd7d3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 17:08:56 +0700 Subject: [PATCH 077/256] Add links to section --- .../lib/components/AuthenticationCard.svelte | 2 +- web/src/lib/components/BlacklistCard.svelte | 2 +- .../lib/components/ContentAnalysisCard.svelte | 2 +- web/src/lib/components/DnsRecordsCard.svelte | 2 +- .../lib/components/HeaderAnalysisCard.svelte | 2 +- web/src/lib/components/ScoreCard.svelte | 73 +++++++++++++------ .../lib/components/SpamAssassinCard.svelte | 2 +- 7 files changed, 55 insertions(+), 30 deletions(-) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index b1a7864..e46fe9e 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -53,7 +53,7 @@ } -
    +

    diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index a3dd010..00e07c1 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -14,7 +14,7 @@ let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props(); -
    +

    diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index 3b7bc95..bc65de0 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -12,7 +12,7 @@ let { contentAnalysis, contentGrade, contentScore }: Props = $props(); -
    +

    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 03b992b..e49ce97 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -26,7 +26,7 @@ ); -
    +

    diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 0a2c198..ac5ad7a 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -13,7 +13,7 @@ let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props(); -
    +

    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index d360c31..d1e6b5d 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -19,6 +19,19 @@ } + +
    @@ -30,40 +43,52 @@ {#if summary}
    -
    - - DNS -
    + +
    + + DNS +
    +
    -
    - - Authentication -
    + +
    + + Authentication +
    +
    -
    - - Blacklists -
    + +
    + + Blacklists +
    +
    -
    - - Headers -
    + +
    + + Headers +
    +
    -
    - - Spam Score -
    + +
    + + Spam Score +
    +
    -
    - - Content -
    + +
    + + Content +
    +
    {/if} diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index 7a588f0..d70a1bd 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -12,7 +12,7 @@ let { spamassassin, spamGrade, spamScore }: Props = $props(); -
    +

    From 8b3ab541ba15b779a2834e55b1d4b97acf0c7bb1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 19:31:55 +0700 Subject: [PATCH 078/256] Add a summary after score --- api/openapi.yaml | 2 +- .../lib/components/AuthenticationCard.svelte | 12 +- .../lib/components/BimiRecordDisplay.svelte | 2 +- .../lib/components/DmarcRecordDisplay.svelte | 2 +- web/src/lib/components/EmailPathCard.svelte | 2 +- web/src/lib/components/GradeDisplay.svelte | 5 +- .../lib/components/PtrRecordsDisplay.svelte | 2 +- .../lib/components/SpfRecordsDisplay.svelte | 2 +- web/src/lib/components/SummaryCard.svelte | 401 ++++++++++++++++++ web/src/lib/components/index.ts | 1 + web/src/routes/test/[test]/+page.svelte | 8 + 11 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/components/SummaryCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 5484f9e..6be919d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -557,7 +557,7 @@ components: example: true importance: type: string - enum: [required, recommended, optional] + enum: [required, recommended, optional, newsletter] description: How important this header is for deliverability example: "required" issues: diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index e46fe9e..c43be7d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -75,7 +75,7 @@
    {#if authentication.iprev} -
    +
    @@ -105,7 +105,7 @@
    -
    +
    {#if authentication.spf}
    @@ -137,7 +137,7 @@
    -
    +
    {#if authentication.dkim && authentication.dkim.length > 0} @@ -176,7 +176,7 @@
    -
    +
    {#if authentication.dmarc} @@ -229,7 +229,7 @@
    -
    +
    {#if authentication.bimi && authentication.bimi.result != "none"} @@ -269,7 +269,7 @@ {#if authentication.arc} -
    +
    diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte index 0d7a1b9..f9aee88 100644 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -9,7 +9,7 @@ {#if bimiRecord} -
    +
    {#if dmarcRecord} -
    +
    {#if receivedChain && receivedChain.length > 0} -
    +
    Email Path (Received Chain)
    {#each receivedChain as hop, i} diff --git a/web/src/lib/components/GradeDisplay.svelte b/web/src/lib/components/GradeDisplay.svelte index 322259b..b503fec 100644 --- a/web/src/lib/components/GradeDisplay.svelte +++ b/web/src/lib/components/GradeDisplay.svelte @@ -2,7 +2,7 @@ interface Props { grade?: string; score: number; - size?: "small" | "medium" | "large"; + size?: "inline" | "small" | "medium" | "large"; } let { grade, score, size = "medium" }: Props = $props(); @@ -36,7 +36,8 @@ } } - function getSizeClass(size: "small" | "medium" | "large"): string { + function getSizeClass(size: "inline" | "small" | "medium" | "large"): string { + if (size === "inline") return "fw-bold"; if (size === "small") return "fs-4"; if (size === "large") return "display-1"; return "fs-2"; diff --git a/web/src/lib/components/PtrRecordsDisplay.svelte b/web/src/lib/components/PtrRecordsDisplay.svelte index 4ba7a81..66b4940 100644 --- a/web/src/lib/components/PtrRecordsDisplay.svelte +++ b/web/src/lib/components/PtrRecordsDisplay.svelte @@ -11,7 +11,7 @@ {#if ptrRecords && ptrRecords.length > 0} -
    +
    {#if spfRecords && spfRecords.length > 0} -
    +
    + import type { Report } from "$lib/api/types.gen"; + import GradeDisplay from "./GradeDisplay.svelte"; + + interface TextSegment { + text: string; + highlight?: { + color: "good" | "warning" | "danger"; + bold?: boolean; + }; + link?: string; + } + + interface Props { + report: Report; + } + + let { report }: Props = $props(); + + function buildSummary(): TextSegment[] { + const segments: TextSegment[] = []; + + // Email sender information + const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; + const hasDkim = report.authentication?.dkim && report.authentication.dkim.length > 0; + const dkimPassed = hasDkim && report.authentication.dkim.some(d => d.result === "pass"); + + segments.push({ text: "Received a " }); + segments.push({ + text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", + highlight: { color: dkimPassed ? "good" : "danger", bold: true }, + link: "#authentication-dkim" + }); + segments.push({ text: " email from " }); + segments.push({ + text: mailFrom, + highlight: { emphasis: true } + }); + + // Server information and hops + const receivedChain = report.header_analysis?.received_chain; + if (receivedChain && receivedChain.length > 0) { + const firstHop = receivedChain[0]; + const serverName = firstHop.from || firstHop.ip || "an unknown server"; + const hopCount = receivedChain.length; + segments.push({ text: ", sent by " }); + segments.push({ + text: serverName, + highlight: { monospace: true }, + link: "#header-details" + }); + segments.push({ text: " after " }); + segments.push({ + text: `${hopCount-1} hop${hopCount-1 !== 1 ? "s" : ""}`, + link: "#email-path" + }); + } + + // Authentication status + const spfResult = report.authentication?.spf?.result; + const dmarcResult = report.authentication?.dmarc?.result; + + segments.push({ text: " which is " }); + if (spfResult === "pass" || dmarcResult === "pass") { + segments.push({ + text: "authenticated", + highlight: { color: "good", bold: true }, + link: "#authentication-details" + }); + segments.push({ text: " to send email on behalf of " }); + segments.push({ text: report.header_analysis?.domain_alignment?.from_domain, highlight: {monospace: true} }); + } else if (spfResult && spfResult !== "none") { + segments.push({ + text: "not authenticated", + highlight: { color: "danger", bold: true }, + link: "#authentication-spf" + }); + segments.push({ text: " (failed authentication checks)" }); + } else { + segments.push({ + text: "not authenticated", + highlight: { color: "warning", bold: true }, + link: "#authentication-details" + }); + segments.push({ text: " (lacks proper authentication)" }); + } + + // IP Reverse DNS (iprev) check + const iprevResult = report.authentication?.iprev; + if (iprevResult) { + segments.push({ text: ". Its reverse IP " }); + if (iprevResult.result === "pass") { + segments.push({ text: "looks " }); + segments.push({ + text: "good", + highlight: { color: "good", bold: true }, + link: "#dns-ptr" + }); + } else if (iprevResult.result === "fail") { + segments.push({ + text: "failed", + highlight: { color: "danger", bold: true }, + link: "#dns-ptr" + }); + segments.push({ text: " to pass the test" }); + } else { + segments.push({ text: "returned " }); + segments.push({ + text: iprevResult.result, + highlight: { color: "warning", bold: true }, + link: "#dns-ptr" + }); + } + } + + // Blacklist status + const blacklists = report.blacklists; + if (blacklists && Object.keys(blacklists).length > 0) { + const allChecks = Object.values(blacklists).flat(); + const listedCount = allChecks.filter(check => check.listed).length; + + segments.push({ text: ". Your server is " }); + if (listedCount > 0) { + segments.push({ + text: `blacklisted on ${listedCount} list${listedCount !== 1 ? "s" : ""}`, + highlight: { color: "danger", bold: true }, + link: "#rbl-details" + }); + } else { + segments.push({ + text: "not blacklisted", + highlight: { color: "good", bold: true }, + link: "#rbl-details" + }); + } + } + + // Domain alignment + const domainAlignment = report.header_analysis?.domain_alignment; + if (domainAlignment) { + segments.push({ text: ". Domain alignment is " }); + if (domainAlignment.aligned || domainAlignment.relaxed_aligned) { + segments.push({ + text: "good", + highlight: { color: "good", bold: true }, + link: "#domain-alignment" + }); + if (!domainAlignment.aligned) { + segments.push({ text: " using organizational domain" }); + } + } else { + segments.push({ + text: "misaligned", + highlight: { color: "danger", bold: true }, + link: "#domain-alignment" + }); + segments.push({ text: ": " }); + segments.push({ text: "Return-Path", highlight: { monospace: true } }); + segments.push({ text: " is set to an address of " }); + segments.push({ text: report.header_analysis?.domain_alignment?.return_path_domain, highlight: { monospace: true } }); + segments.push({ text: ", you should " }); + segments.push({ + text: "update it", + highlight: { bold: true }, + link: "#domain-alignment" + }); + } + } + + // DMARC policy check + const dmarcRecord = report.dns_results?.dmarc_record; + if (dmarcRecord) { + if (!dmarcRecord.record) { + segments.push({ text: ". You " }); + segments.push({ + text: "don't have", + highlight: { color: "danger", bold: true }, + link: "#dns-dmarc" + }); + segments.push({ text: " a DMARC record, " }); + segments.push({ text: "consider adding at least a record with the '", highlight: { bold : true } }); + segments.push({ text: "none", highlight: { monospace: true, bold: true } }); + segments.push({ text: "' policy", highlight: { bold : true } }); + } else if (!dmarcRecord.valid) { + segments.push({ text: ". Your DMARC record has " }); + segments.push({ + text: "issues", + highlight: { color: "danger", bold: true }, + link: "#dns-dmarc" + }); + } else if (dmarcRecord.policy === "none") { + segments.push({ text: ". Your DMARC policy is " }); + segments.push({ + text: "set to 'none'", + highlight: { color: "warning", bold: true }, + link: "#dns-dmarc" + }); + segments.push({ text: ", which provides monitoring but no protection" }); + } else if (dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject") { + segments.push({ text: ". Your DMARC policy is '" }); + segments.push({ + text: dmarcRecord.policy, + highlight: { color: "good", bold: true, monospace: true }, + link: "#dns-dmarc" + }); + segments.push({ text: "'" }); + if (dmarcRecord.policy === "reject") { + segments.push({ text: ", which is great" }); + } else { + segments.push({ text: ", consider switching to reject" }); + } + } + } else if (dmarcResult && dmarcResult.result === "fail") { + segments.push({ text: ". DMARC check " }); + segments.push({ + text: "failed", + highlight: { color: "danger", bold: true }, + link: "#authentication-dmarc" + }); + } + + // BIMI + if (dmarcRecord.valid && dmarcRecord.policy != "none") { + const bimiResult = report.authentication?.bimi; + const bimiRecord = report.dns_results?.bimi_record; + if (bimiRecord?.valid) { + segments.push({ text: ". Your domain includes " }); + segments.push({ + text: "BIMI", + highlight: { color: "good", bold: true }, + link: "#dns-bimi" + }); + segments.push({ text: " for brand indicator display" }); + } else if (bimiResult && bimiResult.details.indexOf("(No BIMI records found)") >= 0) { + segments.push({ text: ". Your domain has no " }); + segments.push({ + text: "BIMI record", + highlight: { color: "warning", bold: true }, + link: "#dns-bimi" + }); + } else if (bimiResult || bimiRecord) { + segments.push({ text: ". Your domain has " }); + segments.push({ + text: "BIMI configured with issues", + highlight: { color: "warning", bold: true }, + link: "#dns-bimi" + }); + } + } + + // ARC + const arcResult = report.authentication?.arc; + if (arcResult && arcResult.result !== "none") { + segments.push({ text: ". " }); + segments.push({ + text: "ARC chain validation", + link: "#authentication-arc" + }); + segments.push({ text: " " }); + if (arcResult.chain_valid) { + segments.push({ + text: "passed", + highlight: { color: "good", bold: true } + }); + segments.push({ text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding` }); + } else { + segments.push({ + text: "failed", + highlight: { color: "danger", bold: true } + }); + segments.push({ text: ", which may indicate issues with email forwarding" }); + } + } + + // Newsletter/marketing headers check + const headers = report.header_analysis?.headers; + const listUnsubscribe = headers?.["list-unsubscribe"]; + const listUnsubscribePost = headers?.["list-unsubscribe-post"]; + + const hasNewsletterHeaders = (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) || + (listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present); + + if (!hasNewsletterHeaders && (listUnsubscribe?.importance === "newsletter" || listUnsubscribePost?.importance === "newsletter")) { + segments.push({ text: ". This email is " }); + segments.push({ + text: "missing unsubscribe headers", + highlight: { color: "warning", bold: true }, + link: "#header-details" + }); + segments.push({ text: " and is " }); + segments.push({ + text: "not suitable for marketing campaigns", + highlight: { bold: true } + }); + } + + // Content/spam assessment + const spamAssassin = report.spamassassin; + const contentScore = report.summary?.content_score || 0; + + segments.push({ text: ". " }); + if (spamAssassin?.is_spam) { + segments.push({ text: "Content is " }); + segments.push({ + text: "flagged as spam", + highlight: { color: "danger", bold: true }, + link: "#spam-details" + }); + segments.push({ text: " and needs review" }); + } else if (contentScore < 50) { + segments.push({ text: "Content quality " }); + segments.push({ + text: "needs improvement", + highlight: { color: "warning", bold: true }, + link: "#content-details" + }); + } else if (contentScore >= 100) { + segments.push({ text: "Content " }); + segments.push({ + text: "looks great", + highlight: { color: "good", bold: true }, + link: "#content-details" + }); + } else if (contentScore >= 80) { + segments.push({ text: "Content " }); + segments.push({ + text: "looks good", + highlight: { color: "good", bold: true }, + link: "#content-details" + }); + } else { + segments.push({ text: "Content " }); + segments.push({ + text: "should be reviewed", + highlight: { color: "warning", bold: true }, + link: "#content-details" + }); + } + + segments.push({ text: "." }); + + return segments; + } + + function getColorClass(color: "good" | "warning" | "danger"): string { + switch (color) { + case "good": + return "text-success"; + case "warning": + return "text-warning"; + case "danger": + return "text-danger"; + } + } + + const summarySegments = $derived(buildSummary()); + + + + +
    +
    +
    + + Summary +
    +

    + {#each summarySegments as segment} + {#if segment.link} + + {segment.text} + + {:else if segment.highlight} + + {segment.text} + + {:else} + {segment.text} + {/if} + {/each} + Overall, your email received a grade {#if report.grade == "A" || report.grade == "A+"}, well done 🎉{/if}! Check the details below 🔽 +

    +
    +
    diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index d3b7909..8b83ae5 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -2,6 +2,7 @@ export { default as FeatureCard } from "./FeatureCard.svelte"; export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; export { default as ScoreCard } from "./ScoreCard.svelte"; +export { default as SummaryCard } from "./SummaryCard.svelte"; export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; export { default as PendingState } from "./PendingState.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 28a140a..c79b9f4 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -5,6 +5,7 @@ import type { Test, Report } from "$lib/api/types.gen"; import { ScoreCard, + SummaryCard, SpamAssassinCard, PendingState, AuthenticationCard, @@ -138,6 +139,13 @@
    + +
    +
    + +
    +
    + {#if report.dns_results}
    From 4bbba66a81089e55bafb37bfb25b6a698ce5c9e7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 10:12:43 +0700 Subject: [PATCH 079/256] Handle local postfix delivery --- pkg/analyzer/headers.go | 25 ++++++++++++++------- pkg/analyzer/headers_test.go | 10 +++++++++ web/src/lib/components/EmailPathCard.svelte | 16 ++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 954f229..854841c 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -450,11 +450,18 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // Normalize whitespace - Received headers can span multiple lines normalized := strings.Join(strings.Fields(receivedValue), " ") - // Extract "from" field - fromRegex := regexp.MustCompile(`(?i)from\s+([^\s(]+)`) - if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 { - from := matches[1] - hop.From = &from + // Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)") + // vs standard "from-first" header (e.g., "from hostname ... by hostname") + isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized)) + + // Extract "from" field - only if not in "by-first" format + // Avoid matching "from" inside parentheses after "by" + if !isByFirst { + fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`) + if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 { + from := matches[1] + hop.From = &from + } } // Extract "by" field @@ -466,14 +473,16 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // Extract "with" field (protocol) - must come after "by" and before "id" or "for" // This ensures we get the mail transfer protocol, not other "with" occurrences - withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)`) + // Avoid matching "with" inside parentheses (like in TLS details) + withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`) if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 { with := matches[1] hop.With = &with } - // Extract "id" field - idRegex := regexp.MustCompile(`(?i)id\s+([^\s;]+)`) + // Extract "id" field - should come after "with" or "by", not inside parentheses + // Match pattern: "id " where value doesn't contain parentheses or semicolons + idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`) if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 { id := matches[1] hop.Id = &id diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 46b4a71..744c16a 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -619,6 +619,16 @@ func TestParseReceivedHeader(t *testing.T) { expectIp: nil, expectHasTs: true, }, + { + name: "Postfix local delivery with userid", + receivedValue: "by grunt.ycc.fr (Postfix, from userid 1000) id 67276801A8; Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectFrom: nil, + expectBy: strPtr("grunt.ycc.fr"), + expectWith: nil, + expectId: strPtr("67276801A8"), + expectIp: nil, + expectHasTs: true, + }, } analyzer := NewHeaderAnalyzer() diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index b70e427..c8b9a67 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -17,20 +17,26 @@
    {receivedChain.length - i} - {hop.reverse || '-'} ({hop.ip}) → {hop.by || 'Unknown'} + {hop.reverse || '-'}{#if hop.ip} ({hop.ip}){/if} → {hop.by || 'Unknown'}
    {hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}
    {#if hop.with || hop.id} -

    +

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

    {/if} From 7ed347c86e5fd565d9982d045cc1621a81672224 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 10:22:16 +0700 Subject: [PATCH 080/256] Improve test display in some circonstancies --- pkg/analyzer/content.go | 22 ++----- .../lib/components/AuthenticationCard.svelte | 36 ++++++----- .../lib/components/ContentAnalysisCard.svelte | 6 +- .../lib/components/DkimRecordsDisplay.svelte | 64 +++++++++++-------- web/src/lib/components/DnsRecordsCard.svelte | 8 +-- web/src/lib/components/EmailPathCard.svelte | 2 +- web/src/lib/components/ScoreCard.svelte | 12 ++-- web/src/lib/components/SummaryCard.svelte | 43 ++++++++++++- web/src/routes/test/[test]/+page.svelte | 2 +- 9 files changed, 118 insertions(+), 77 deletions(-) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 27eea4b..613e5d5 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -751,9 +751,7 @@ func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, s brokenLinks++ } } - if brokenLinks == 0 { - score += 20 - } + score += 20 * brokenLinks / len(results.Links) // Too much links, 10 points penalty if len(results.Links) > 30 { score -= 10 @@ -771,11 +769,7 @@ func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, s noAltCount++ } } - if noAltCount == 0 { - score += 15 - } else if noAltCount < len(results.Images) { - score += 7 - } + score += 15 * noAltCount / len(results.Images) } else { // No images is Ok score += 15 @@ -795,20 +789,12 @@ func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, s // Penalize suspicious URLs (deduct up to 5 points) if len(results.SuspiciousURLs) > 0 { - penalty := len(results.SuspiciousURLs) - if penalty > 5.0 { - penalty = 5 - } - score -= penalty + score -= min(len(results.SuspiciousURLs), 5) } // Penalize harmful HTML tags (deduct 20 points per harmful tag, max 40 points) if len(results.HarmfullIssues) > 0 { - penalty := len(results.HarmfullIssues) * 20 - if penalty > 40 { - penalty = 40 - } - score -= penalty + score -= min(len(results.HarmfullIssues)*20, 40) } // Ensure score is between 0 and 100 diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index c43be7d..c14d4ec 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -12,7 +12,7 @@ let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props(); - function getAuthResultClass(result: string): string { + function getAuthResultClass(result: string, noneIsFail: boolean): string { switch (result) { case "pass": return "text-success"; @@ -22,12 +22,14 @@ case "softfail": case "neutral": return "text-warning"; + case "none": + return noneIsFail ? "text-danger" : "text-muted"; default: return "text-muted"; } } - function getAuthResultIcon(result: string): string { + function getAuthResultIcon(result: string, noneIsFail: boolean): string { switch (result) { case "pass": return "bi-check-circle-fill"; @@ -38,6 +40,8 @@ return "bi-exclamation-circle-fill"; case "missing": return "bi-dash-circle-fill"; + case "none": + return noneIsFail ? "bi-x-circle-fill" : "bi-question-circle"; default: return "bi-question-circle"; } @@ -77,10 +81,10 @@ {#if authentication.iprev}
    - +
    IP Reverse DNS - + {authentication.iprev.result} {#if authentication.iprev.ip} @@ -107,10 +111,10 @@
    {#if authentication.spf} - +
    SPF - + {authentication.spf.result} {#if authentication.spf.domain} @@ -140,10 +144,10 @@
    {#if authentication.dkim && authentication.dkim.length > 0} - +
    DKIM - + {authentication.dkim[0].result} {#if authentication.dkim[0].domain} @@ -179,10 +183,10 @@
    {#if authentication.dmarc} - +
    DMARC - + {authentication.dmarc.result} {#if authentication.dmarc.domain} @@ -205,11 +209,13 @@
    {/snippet} - {#if authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} - {@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")} - {@render DMARCPolicy(policy)} - {:else if authentication.dmarc.domain} - {@render DMARCPolicy(dnsResults.dmarc_record.policy)} + {#if authentication.dmarc.result != "none"} + {#if authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} + {@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")} + {@render DMARCPolicy(policy)} + {:else if authentication.dmarc.domain} + {@render DMARCPolicy(dnsResults.dmarc_record.policy)} + {/if} {/if} {#if authentication.dmarc.details}
    {authentication.dmarc.details}
    diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index bc65de0..b5fc380 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -71,7 +71,7 @@ {#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0}
    -
    Content Issues
    +
    Content Issues
    {#each contentAnalysis.html_issues as issue}
    @@ -97,7 +97,7 @@ {#if contentAnalysis.links && contentAnalysis.links.length > 0}
    -
    Links ({contentAnalysis.links.length})
    +
    Links ({contentAnalysis.links.length})
    @@ -132,7 +132,7 @@ {#if contentAnalysis.images && contentAnalysis.images.length > 0}
    -
    Images ({contentAnalysis.images.length})
    +
    Images ({contentAnalysis.images.length})
    diff --git a/web/src/lib/components/DkimRecordsDisplay.svelte b/web/src/lib/components/DkimRecordsDisplay.svelte index 45c67b3..fad7205 100644 --- a/web/src/lib/components/DkimRecordsDisplay.svelte +++ b/web/src/lib/components/DkimRecordsDisplay.svelte @@ -8,30 +8,31 @@ let { dkimRecords }: Props = $props(); // Compute overall validity - const dkimIsValid = $derived( - dkimRecords?.reduce((acc, r) => acc && r.valid, true) ?? false - ); + const dkimIsValid = $derived(dkimRecords?.reduce((acc, r) => acc && r.valid, true) ?? false); -{#if dkimRecords && dkimRecords.length > 0} -
    -
    -
    - - DomainKeys Identified Mail -
    - DKIM -
    -
    -

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

    -
    -
    +
    +
    +
    + + DomainKeys Identified Mail +
    + DKIM +
    +
    +

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

    +
    +
    + {#if dkimRecords && dkimRecords.length > 0} {#each dkimRecords as dkim}
    @@ -48,17 +49,26 @@
    {#if dkim.record}
    - Record:
    - {dkim.record} + Record:
    + {dkim.record}
    {/if} {#if dkim.error}
    - Error: {dkim.error} + Error: + {dkim.error}
    {/if}
    {/each} -
    + {:else} +
    + + No DKIM signatures found in this email. DKIM provides cryptographic authentication and + helps avoid spoofing, thus improving deliverability. +
    + {/if}
    -{/if} +
    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index e49ce97..3a73432 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -63,8 +63,8 @@ {#if receivedChain && receivedChain.length > 0}
    -

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

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

    {/if} @@ -84,7 +84,7 @@
    -

    +

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

    {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)} @@ -116,7 +116,7 @@
    -

    +

    From Domain: {dnsResults.from_domain}

    {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index c8b9a67..422ba0a 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -17,7 +17,7 @@
    {receivedChain.length - i} - {hop.reverse || '-'}{#if hop.ip} ({hop.ip}){/if} → {hop.by || 'Unknown'} + {hop.reverse || '-'} {#if hop.ip}({hop.ip}){/if} → {hop.by || 'Unknown'}
    {hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}
    diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index d1e6b5d..11e5396 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -42,7 +42,7 @@ {#if summary}
    -
    + -
    + -
    + -
    + -
    + -
    +
    diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 393a847..43ea811 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -85,6 +85,41 @@ segments.push({ text: " (lacks proper authentication)" }); } + // SPF specific issues + if (spfResult && spfResult !== "pass") { + segments.push({ text: ". SPF check " }); + if (spfResult === "fail") { + segments.push({ + text: "failed", + highlight: { color: "danger", bold: true }, + link: "#authentication-spf" + }); + segments.push({ text: ", the sending server is not authorized to send mail for this domain" }); + } else if (spfResult === "softfail") { + segments.push({ + text: "soft-failed", + highlight: { color: "warning", bold: true }, + link: "#authentication-spf" + }); + segments.push({ text: ", the sending server may not be authorized" }); + } else if (spfResult === "temperror" || spfResult === "permerror") { + segments.push({ + text: "encountered an error", + highlight: { color: "warning", bold: true }, + link: "#authentication-spf" + }); + segments.push({ text: ", check your SPF record configuration" }); + } else if (spfResult === "none") { + segments.push({ text: "Your domain has " }); + segments.push({ + text: "no SPF record", + highlight: { color: "danger", bold: true }, + link: "#dns-spf" + }); + segments.push({ text: ", you should add one to specify which servers can send email on your behalf" }); + } + } + // IP Reverse DNS (iprev) check const iprevResult = report.authentication?.iprev; if (iprevResult) { @@ -207,7 +242,9 @@ if (dmarcRecord.policy === "reject") { segments.push({ text: ", which is great" }); } else { - segments.push({ text: ", consider switching to reject" }); + segments.push({ text: ", consider switching to '" }); + segments.push({ text: "reject", highlight: { monospace: true, bold: true } }); + segments.push({ text: "'" }); } } } else if (dmarcResult && dmarcResult.result === "fail") { @@ -238,6 +275,8 @@ highlight: { color: "warning", bold: true }, link: "#dns-bimi" }); + segments.push({ text: ", you could " }); + segments.push({ text: "add a record to decline participation", highlight: { bold: true } }); } else if (bimiResult || bimiRecord) { segments.push({ text: ". Your domain has " }); segments.push({ @@ -395,7 +434,7 @@ {segment.text} {/if} {/each} - Overall, your email received a grade {#if report.grade == "A" || report.grade == "A+"}, well done 🎉{/if}! Check the details below 🔽 + Overall, your email received a grade {#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: you could have delivery issues with common providers.{:else if report.grade == "F"}: it will most likely be rejected by most providers.{:else}!{/if} Check the details below 🔽

    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c79b9f4..59697e2 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -102,7 +102,7 @@ - {test ? `Test ${test.id.slice(0, 7)} - happyDeliver` : "Loading..."} + {report ? `Test of ${report.dns_results.from_domain} ${report.test_id.slice(0, 7)}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver
    From 29cb2cf1f97b9cdf5b6b06bcf3e84bd5efd836b1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 10:46:54 +0700 Subject: [PATCH 081/256] Headers value for Date and email related are now parsed --- pkg/analyzer/headers.go | 93 ++++++++++++++---- pkg/analyzer/headers_test.go | 178 +++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 18 deletions(-) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 854841c..7e65571 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -24,6 +24,7 @@ package analyzer import ( "fmt" "net" + "net/mail" "regexp" "strings" "time" @@ -153,6 +154,30 @@ func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { return len(parts[0]) > 0 && len(parts[1]) > 0 } +// parseEmailDate attempts to parse an email date string using common email date formats +// Returns the parsed time and an error if parsing fails +func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) { + // Remove timezone name in parentheses if present + dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "") + + // Try parsing with common email date formats + formats := []string{ + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04:05 -0700", + } + + for _, format := range formats { + if parsedTime, err := time.Parse(format, dateStr); err == nil { + return parsedTime, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr) +} + // isNoReplyAddress checks if a header check represents a no-reply email address func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool { if !headerCheck.Present || headerCheck.Value == nil { @@ -176,6 +201,39 @@ func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool { return false } +// validateAddressHeader validates email address header using net/mail parser +// and returns the normalized address string in "Name " format +func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) { + // Try to parse as a single address first + if addr, err := mail.ParseAddress(value); err == nil { + return h.formatAddress(addr), nil + } + + // If single address parsing fails, try parsing as an address list + // (for headers like To, Cc that can contain multiple addresses) + if addrs, err := mail.ParseAddressList(value); err != nil { + return "", err + } else { + // Join multiple addresses with ", " + result := "" + for i, addr := range addrs { + if i > 0 { + result += ", " + } + result += h.formatAddress(addr) + } + return result, nil + } +} + +// formatAddress formats a mail.Address as "Name " or just "email" if no name +func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { + if addr.Name != "" { + return fmt.Sprintf("%s <%s>", addr.Name, addr.Address) + } + return addr.Address +} + // GenerateHeaderAnalysis creates structured header analysis from email func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { if email == nil { @@ -262,7 +320,20 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp headerIssues = append(headerIssues, "Invalid Message-ID format (should be )") } case "Date": - // Could add date validation here + // Validate date format + if _, err := h.parseEmailDate(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err)) + } + case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path": + // Parse address header using net/mail and get normalized address + if normalizedAddr, err := h.validateAddressHeader(value); err != nil { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err)) + } else { + // Use the normalized address as the value + check.Value = &normalizedAddr + } } check.Valid = &valid @@ -516,23 +587,9 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 { timestampStr := strings.TrimSpace(matches[1]) - // Remove timezone name in parentheses if present - timestampStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(timestampStr, "") - - // Try parsing with common email date formats - formats := []string{ - time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" - time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" - "Mon, 2 Jan 2006 15:04:05 -0700", - "Mon, 2 Jan 2006 15:04:05 MST", - "2 Jan 2006 15:04:05 -0700", - } - - for _, format := range formats { - if parsedTime, err := time.Parse(format, timestampStr); err == nil { - hop.Timestamp = &parsedTime - break - } + // Use the dedicated date parsing function + if parsedTime, err := h.parseEmailDate(timestampStr); err == nil { + hop.Timestamp = &parsedTime } } diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 744c16a..7896a5c 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -724,6 +724,184 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { } } +func TestHeaderAnalyzer_ParseEmailDate(t *testing.T) { + tests := []struct { + name string + dateStr string + expectError bool + expectYear int + expectMonth int + expectDay int + }{ + { + name: "RFC1123Z format", + dateStr: "Mon, 02 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "RFC1123 format", + dateStr: "Mon, 02 Jan 2006 15:04:05 MST", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Single digit day", + dateStr: "Mon, 2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "Without day of week", + dateStr: "2 Jan 2006 15:04:05 -0700", + expectError: false, + expectYear: 2006, + expectMonth: 1, + expectDay: 2, + }, + { + name: "With timezone name in parentheses", + dateStr: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectError: false, + expectYear: 2024, + expectMonth: 1, + expectDay: 1, + }, + { + name: "With timezone name in parentheses 2", + dateStr: "Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 19, + }, + { + name: "With CEST timezone", + dateStr: "Fri, 24 Oct 2025 04:17:25 +0200 (CEST)", + expectError: false, + expectYear: 2025, + expectMonth: 10, + expectDay: 24, + }, + { + name: "Invalid date format", + dateStr: "not a date", + expectError: true, + }, + { + name: "Empty string", + dateStr: "", + expectError: true, + }, + { + name: "ISO 8601 format (should fail)", + dateStr: "2024-01-01T12:00:00Z", + expectError: true, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := analyzer.parseEmailDate(tt.dateStr) + + if tt.expectError { + if err == nil { + t.Errorf("parseEmailDate(%q) expected error, got nil", tt.dateStr) + } + } else { + if err != nil { + t.Errorf("parseEmailDate(%q) unexpected error: %v", tt.dateStr, err) + return + } + + if result.Year() != tt.expectYear { + t.Errorf("Year = %d, want %d", result.Year(), tt.expectYear) + } + if int(result.Month()) != tt.expectMonth { + t.Errorf("Month = %d, want %d", result.Month(), tt.expectMonth) + } + if result.Day() != tt.expectDay { + t.Errorf("Day = %d, want %d", result.Day(), tt.expectDay) + } + } + }) + } +} + +func TestCheckHeader_DateValidation(t *testing.T) { + tests := []struct { + name string + dateValue string + expectedValid bool + expectedIssuesLen int + }{ + { + name: "Valid RFC1123Z date", + dateValue: "Mon, 02 Jan 2006 15:04:05 -0700", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Valid date with timezone name", + dateValue: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)", + expectedValid: true, + expectedIssuesLen: 0, + }, + { + name: "Invalid date format", + dateValue: "2024-01-01", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Invalid date string", + dateValue: "not a date", + expectedValid: false, + expectedIssuesLen: 1, + }, + { + name: "Empty date", + dateValue: "", + expectedValid: false, + expectedIssuesLen: 1, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Date": tt.dateValue, + }), + } + + check := analyzer.checkHeader(email, "Date", "required") + + if check.Valid != nil && *check.Valid != tt.expectedValid { + t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid) + } + + issuesLen := 0 + if check.Issues != nil { + issuesLen = len(*check.Issues) + } + if issuesLen != tt.expectedIssuesLen { + t.Errorf("Issues length = %d, want %d (issues: %v)", issuesLen, tt.expectedIssuesLen, check.Issues) + } + }) + } +} + // Helper functions for testing func strPtr(s string) *string { return &s From aa35ab223daca9cab631eb80989fda1d43b30244 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 10:50:49 +0700 Subject: [PATCH 082/256] Align no DMARC authentication with on DMARC DNS record: grade C --- pkg/analyzer/authentication.go | 10 +++++----- pkg/analyzer/authentication_test.go | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 84bbb9e..f18bbb7 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -532,7 +532,7 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe } } - // DKIM (20 points) - at least one passing signature + // DKIM (25 points) - at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { hasPass := false for _, dkim := range *results.Dkim { @@ -542,18 +542,18 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe } } if hasPass { - score += 20 + score += 25 } else { // Has DKIM signatures but none passed - score += 7 + score += 10 } } - // DMARC (30 points) + // DMARC (25 points) if results.Dmarc != nil { switch results.Dmarc.Result { case api.AuthResultResultPass: - score += 30 + score += 25 case api.AuthResultResultNone: score += 10 default: // fail diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 8535ff2..554d423 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -265,7 +265,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30 + expectedScore: 75, // SPF=25 + DKIM=25 + DMARC=25 }, { name: "SPF and DKIM only", @@ -277,7 +277,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 60, // SPF=30 + DKIM=30 + expectedScore: 50, // SPF=25 + DKIM=25 }, { name: "SPF fail, DKIM pass", @@ -289,7 +289,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 30, // SPF=0 + DKIM=30 + expectedScore: 25, // SPF=0 + DKIM=25 }, { name: "SPF softfail", @@ -298,7 +298,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultSoftfail, }, }, - expectedScore: 5, + expectedScore: 4, }, { name: "No authentication", @@ -315,7 +315,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 40, // SPF (30) + BIMI (10) + expectedScore: 35, // SPF (25) + BIMI (10) }, } From 8fe8581b782eae5bb0f493d94a5465608d03f37a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 11:03:26 +0700 Subject: [PATCH 083/256] Handle declined auth result --- api/openapi.yaml | 2 +- pkg/analyzer/authentication.go | 2 +- pkg/analyzer/scoring.go | 2 +- web/src/lib/components/AuthenticationCard.svelte | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 6be919d..139a512 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -688,7 +688,7 @@ components: properties: result: type: string - enum: [pass, fail, none, neutral, softfail, temperror, permerror] + enum: [pass, fail, none, neutral, softfail, temperror, permerror, declined] description: Authentication result example: "pass" domain: diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index f18bbb7..e89cb77 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -566,7 +566,7 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe switch results.Bimi.Result { case api.AuthResultResultPass: score += 10 - case api.AuthResultResultNone: + case api.AuthResultResultDeclined: score += 5 default: // fail score += 0 diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 84756a7..ae91d4f 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -30,7 +30,7 @@ func ScoreToGrade(score int) string { switch { case score > 100: return "A+" - case score > 95: + case score >= 95: return "A" case score >= 85: return "B" diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index c14d4ec..344495c 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -22,6 +22,8 @@ case "softfail": case "neutral": return "text-warning"; + case "declined": + return "text-info"; case "none": return noneIsFail ? "text-danger" : "text-muted"; default: @@ -40,6 +42,8 @@ return "bi-exclamation-circle-fill"; case "missing": return "bi-dash-circle-fill"; + case "declined": + return "bi-dash-circle"; case "none": return noneIsFail ? "bi-x-circle-fill" : "bi-question-circle"; default: From 9970e957d57307bc965c90d14bd7805bc17e37cf Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 11:18:00 +0700 Subject: [PATCH 084/256] Handle SPF redirect --- pkg/analyzer/dns.go | 51 ++++++++++++++++- pkg/analyzer/dns_test.go | 57 ++++++++++++++++++- .../lib/components/SpfRecordsDisplay.svelte | 11 +++- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 542d704..e75b3ac 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -269,6 +269,18 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, Error: errMsg, }) + // Check for redirect= modifier first (it replaces the entire SPF policy) + redirectDomain := d.extractSPFRedirect(spfRecord) + if redirectDomain != "" { + // redirect= replaces the current domain's policy entirely + // Only follow if no other mechanisms matched (per RFC 7208) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) + if redirectRecords != nil { + results = append(results, *redirectRecords...) + } + return &results + } + // Extract and resolve include: directives includes := d.extractSPFIncludes(spfRecord) for _, includeDomain := range includes { @@ -294,6 +306,17 @@ func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { return includes } +// extractSPFRedirect extracts the redirect= domain from an SPF record +// The redirect= modifier replaces the current domain's SPF policy with that of the target domain +func (d *DNSAnalyzer) extractSPFRedirect(record string) string { + re := regexp.MustCompile(`redirect=([^\s]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + // validateSPF performs basic SPF record validation func (d *DNSAnalyzer) validateSPF(record string) bool { // Must start with v=spf1 @@ -301,6 +324,11 @@ func (d *DNSAnalyzer) validateSPF(record string) bool { return false } + // Check for redirect= modifier (which replaces the need for an 'all' mechanism) + if strings.Contains(record, "redirect=") { + return true + } + // Check for common syntax issues // Should have a final mechanism (all, +all, -all, ~all, ?all) validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} @@ -752,8 +780,27 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string // SPF Records: 20 points // SPF is essential for email authentication if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { - // Check the main domain's SPF record (first in the list) - mainSPF := (*results.SpfRecords)[0] + // Find the main SPF record by skipping redirects + // Loop through records to find the last redirect or the first non-redirect + mainSPFIndex := 0 + for i := 0; i < len(*results.SpfRecords); i++ { + spfRecord := (*results.SpfRecords)[i] + if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { + // This is a redirect, check if there's a next record + if i+1 < len(*results.SpfRecords) { + mainSPFIndex = i + 1 + } else { + // Redirect exists but no target record found + break + } + } else { + // Found a non-redirect record + mainSPFIndex = i + break + } + } + + mainSPF := (*results.SpfRecords)[mainSPFIndex] if mainSPF.Valid { // Full points for valid SPF score += 15 diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index d3deb20..10b7b98 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -82,13 +82,23 @@ func TestValidateSPF(t *testing.T) { record: "v=spf1 mx ?all", expected: true, }, + { + name: "Valid SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expected: true, + }, + { + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expected: true, + }, { name: "Invalid SPF - no version", record: "include:_spf.example.com -all", expected: false, }, { - name: "Invalid SPF - no all mechanism", + name: "Invalid SPF - no all mechanism or redirect", record: "v=spf1 include:_spf.example.com", expected: false, }, @@ -111,6 +121,51 @@ func TestValidateSPF(t *testing.T) { } } +func TestExtractSPFRedirect(t *testing.T) { + tests := []struct { + name string + record string + expectedRedirect string + }{ + { + name: "SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectedRedirect: "_spf.example.com", + }, + { + name: "SPF with redirect and other mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", + expectedRedirect: "_spf.google.com", + }, + { + name: "SPF without redirect", + record: "v=spf1 include:_spf.example.com -all", + expectedRedirect: "", + }, + { + name: "SPF with only all mechanism", + record: "v=spf1 -all", + expectedRedirect: "", + }, + { + name: "Empty record", + record: "", + expectedRedirect: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractSPFRedirect(tt.record) + if result != tt.expectedRedirect { + t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) + } + }) + } +} + func TestValidateDKIM(t *testing.T) { tests := []struct { name string diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte index 20419b6..6d7d621 100644 --- a/web/src/lib/components/SpfRecordsDisplay.svelte +++ b/web/src/lib/components/SpfRecordsDisplay.svelte @@ -11,6 +11,9 @@ const spfIsValid = $derived( spfRecords?.reduce((acc, r) => acc && r.valid, true) ?? false ); + const spfCanBeImprove = $derived( + spfRecords.length > 0 && spfRecords.filter((r) => !r.record.includes(" redirect="))[0]?.all_qualifier != "-" + ); {#if spfRecords && spfRecords.length > 0} @@ -19,8 +22,10 @@
    @@ -62,7 +67,7 @@ {:else if spf.all_qualifier === '?'} Neutral (?all) {/if} - {#if index === 0} + {#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
    {#if spf.all_qualifier === '-'} All unauthorized servers will be rejected. This is the recommended strict policy. From 255027d00be33c39563472c966692262e81e24d3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 11:35:25 +0700 Subject: [PATCH 085/256] Start bad spam score to B --- pkg/analyzer/spamassassin.go | 4 ++-- pkg/analyzer/spamassassin_test.go | 2 +- web/src/lib/components/SummaryCard.svelte | 24 ++++++++++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 6ab41cc..cb80fe6 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -214,7 +214,7 @@ func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssass return 0, "F" // Failed spam test } else { // Linear scale between 0 and required threshold - percentage := 100 - int(math.Round(float64(score*100/result.RequiredScore))) - return percentage, ScoreToGrade(percentage - 15) + percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore)))) + return percentage, ScoreToGrade(percentage - 5) } } diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index 16ff854..b539f24 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -181,7 +181,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 2.0, RequiredScore: 5.0, }, - expectedScore: 60, // 100 - round(2*100/5) = 100 - 40 = 60 + expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60 }, { name: "Score at threshold", diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 43ea811..971c1ac 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -336,6 +336,7 @@ // Content/spam assessment const spamAssassin = report.spamassassin; const contentScore = report.summary?.content_score || 0; + const spamScore = report.summary?.spam_score || 0; segments.push({ text: ". " }); if (spamAssassin?.is_spam) { @@ -353,13 +354,34 @@ highlight: { color: "warning", bold: true }, link: "#content-details" }); - } else if (contentScore >= 100) { + } else if (contentScore >= 100 && spamScore >= 100) { segments.push({ text: "Content " }); segments.push({ text: "looks great", highlight: { color: "good", bold: true }, link: "#content-details" }); + } else if (spamScore < 50) { + segments.push({ text: "Your " }); + segments.push({ + text: "spam score", + highlight: { color: "danger", bold: true }, + link: "#spam-details" + }); + segments.push({ text: " is low" }); + if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { + segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + } + } else if (spamScore < 90) { + segments.push({ text: "Pay attention to your " }); + segments.push({ + text: "spam score", + highlight: { color: "warning", bold: true }, + link: "#spam-details" + }); + if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { + segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + } } else if (contentScore >= 80) { segments.push({ text: "Content " }); segments.push({ From 115da7287473fa0df88fb8d284a1aeb5972fffc7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 11:49:32 +0700 Subject: [PATCH 086/256] Handle x-google-dkim authentication result --- api/openapi.yaml | 3 + pkg/analyzer/authentication.go | 48 ++++++++++++++++ pkg/analyzer/authentication_test.go | 55 +++++++++++++++++++ .../lib/components/AuthenticationCard.svelte | 30 ++++++++++ 4 files changed, 136 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index 139a512..a178dc9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -680,6 +680,9 @@ components: $ref: '#/components/schemas/ARCResult' iprev: $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) AuthResult: type: object diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index e89cb77..2003c48 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -134,6 +134,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Iprev = a.parseIPRevResult(part) } } + + // Parse x-google-dkim + if strings.HasPrefix(part, "x-google-dkim=") { + if results.XGoogleDkim == nil { + results.XGoogleDkim = a.parseXGoogleDKIMResult(part) + } + } } } @@ -299,6 +306,37 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult return result } +// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results +// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-google-dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + return result +} + // 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 { @@ -549,6 +587,16 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe } } + // X-Google-DKIM (optional) - penalty if failed + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case api.AuthResultResultPass: + // pass: don't alter the score + default: // fail + score -= 12 + } + } + // DMARC (25 points) if results.Dmarc != nil { switch results.Dmarc.Result { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 554d423..f0b7163 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -1261,6 +1261,61 @@ func TestParseIPRevResult(t *testing.T) { } } +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "x-google-dkim pass with domain", + part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", + expectedResult: api.AuthResultResultPass, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: api.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +} + func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 344495c..d30ff39 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -183,6 +183,36 @@
    + + {#if authentication.x_google_dkim} +
    +
    + +
    + X-Google-DKIM + + {authentication.x_google_dkim.result} + + {#if authentication.x_google_dkim.domain} +
    + Domain: + {authentication.x_google_dkim.domain} +
    + {/if} + {#if authentication.x_google_dkim.selector} +
    + Selector: + {authentication.x_google_dkim.selector} +
    + {/if} + {#if authentication.x_google_dkim.details} +
    {authentication.x_google_dkim.details}
    + {/if} +
    +
    +
    + {/if} +
    From a700db0873090398ceba160c076df26c76a20ce3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 14:04:44 +0700 Subject: [PATCH 087/256] Split authentication.go in one file per check --- pkg/analyzer/authentication.go | 467 +-------- pkg/analyzer/authentication_arc.go | 183 ++++ pkg/analyzer/authentication_arc_test.go | 150 +++ pkg/analyzer/authentication_bimi.go | 75 ++ pkg/analyzer/authentication_bimi_test.go | 94 ++ pkg/analyzer/authentication_dkim.go | 115 +++ pkg/analyzer/authentication_dkim_test.go | 328 ++++++ pkg/analyzer/authentication_dmarc.go | 68 ++ pkg/analyzer/authentication_dmarc_test.go | 69 ++ pkg/analyzer/authentication_iprev.go | 73 ++ pkg/analyzer/authentication_iprev_test.go | 225 ++++ pkg/analyzer/authentication_spf.go | 105 ++ pkg/analyzer/authentication_spf_test.go | 212 ++++ pkg/analyzer/authentication_test.go | 965 ------------------ pkg/analyzer/authentication_x_google_dkim.go | 73 ++ .../authentication_x_google_dkim_test.go | 83 ++ 16 files changed, 1860 insertions(+), 1425 deletions(-) create mode 100644 pkg/analyzer/authentication_arc.go create mode 100644 pkg/analyzer/authentication_arc_test.go create mode 100644 pkg/analyzer/authentication_bimi.go create mode 100644 pkg/analyzer/authentication_bimi_test.go create mode 100644 pkg/analyzer/authentication_dkim.go create mode 100644 pkg/analyzer/authentication_dkim_test.go create mode 100644 pkg/analyzer/authentication_dmarc.go create mode 100644 pkg/analyzer/authentication_dmarc_test.go create mode 100644 pkg/analyzer/authentication_iprev.go create mode 100644 pkg/analyzer/authentication_iprev_test.go create mode 100644 pkg/analyzer/authentication_spf.go create mode 100644 pkg/analyzer/authentication_spf_test.go create mode 100644 pkg/analyzer/authentication_x_google_dkim.go create mode 100644 pkg/analyzer/authentication_x_google_dkim_test.go diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 2003c48..bc6ae38 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -22,9 +22,6 @@ package analyzer import ( - "fmt" - "regexp" - "slices" "strings" "git.happydns.org/happyDeliver/internal/api" @@ -144,399 +141,6 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, } } -// parseSPFResult parses SPF result from Authentication-Results -// Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`spf=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain - domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - email := matches[1] - // Extract domain from email - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) - - return result -} - -// parseDKIMResult parses DKIM result from Authentication-Results -// Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) - - return result -} - -// parseDMARCResult parses DMARC result from Authentication-Results -// Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dmarc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.from) - domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) - - return result -} - -// parseBIMIResult parses BIMI result from Authentication-Results -// Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`bimi=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.selector or selector) - selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) - - return result -} - -// parseARCResult parses ARC result from Authentication-Results -// Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} - - // Extract result (pass, fail, none) - re := regexp.MustCompile(`arc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.ARCResultResult(resultStr) - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) - - return result -} - -// parseIPRevResult parses IP reverse lookup result from Authentication-Results -// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) -func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { - result := &api.IPRevResult{} - - // Extract result (pass, fail, temperror, permerror, none) - re := regexp.MustCompile(`iprev=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.IPRevResultResult(resultStr) - } - - // Extract IP address (smtp.remote-ip or remote-ip) - ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) - if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { - ip := matches[1] - result.Ip = &ip - } - - // Extract hostname from parentheses - hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) - if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { - hostname := matches[1] - result.Hostname = &hostname - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) - - return result -} - -// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results -// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 -func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`x-google-dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - though not always present in x-google-dkim - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) - - return result -} - -// parseARCHeaders parses ARC headers from email message -// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { - // Get all ARC-related headers - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - - // If no ARC headers present, return nil - if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { - return nil - } - - result := &api.ARCResult{ - Result: api.ARCResultResultNone, - } - - // Count the ARC chain length (number of sets) - chainLength := len(arcSeal) - result.ChainLength = &chainLength - - // Validate the ARC chain - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - result.ChainValid = &chainValid - - // Determine overall result - if chainLength == 0 { - result.Result = api.ARCResultResultNone - details := "No ARC chain present" - result.Details = &details - } else if !chainValid { - result.Result = api.ARCResultResultFail - details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) - result.Details = &details - } else { - result.Result = api.ARCResultResultPass - details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) - result.Details = &details - } - - return result -} - -// enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { - if arcResult == nil { - return - } - - // Get ARC headers - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - - // Set chain length if not already set - if arcResult.ChainLength == nil { - chainLength := len(arcSeal) - arcResult.ChainLength = &chainLength - } - - // Validate chain if not already validated - if arcResult.ChainValid == nil { - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - arcResult.ChainValid = &chainValid - } -} - -// validateARCChain validates the ARC chain for completeness -// Each instance should have all three headers with matching instance numbers -func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { - // All three header types should have the same count - if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { - return false - } - - if len(arcSeal) == 0 { - return true // No ARC chain is technically valid - } - - // Extract instance numbers from each header type - sealInstances := a.extractARCInstances(arcSeal) - sigInstances := a.extractARCInstances(arcMessageSig) - authInstances := a.extractARCInstances(arcAuthResults) - - // Check that all instance numbers match and are sequential starting from 1 - if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { - return false - } - - // Verify instances are sequential from 1 to N - for i := 1; i <= len(sealInstances); i++ { - if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { - return false - } - } - - return true -} - -// extractARCInstances extracts instance numbers from ARC headers -func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { - var instances []int - re := regexp.MustCompile(`i=(\d+)`) - - for _, header := range headers { - if matches := re.FindStringSubmatch(header); len(matches) > 1 { - var instance int - fmt.Sscanf(matches[1], "%d", &instance) - instances = append(instances, instance) - } - } - - return instances -} - -// pluralize returns "y" or "ies" based on count -func pluralize(count int) string { - if count == 1 { - return "y" - } - return "ies" -} - -// parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { - receivedSPF := email.Header.Get("Received-SPF") - if receivedSPF == "" { - return nil - } - - result := &api.AuthResult{} - - // Extract result (first word) - parts := strings.Fields(receivedSPF) - if len(parts) > 0 { - resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) - } - - result.Details = &receivedSPF - - // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) - if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - email := matches[1] - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - return result -} - -// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header -func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { - var results []api.AuthResult - - // Get all DKIM-Signature headers - dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] - for _, dkimHeader := range dkimHeaders { - result := api.AuthResult{ - Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone - } - - // Extract domain (d=) - domainRe := regexp.MustCompile(`d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (s=) - selectorRe := regexp.MustCompile(`s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - details := "DKIM signature present (verification status unknown)" - result.Details = &details - - results = append(results, result) - } - - return results -} - -// textprotoCanonical converts a header name to canonical form -func textprotoCanonical(s string) string { - // Simple implementation - capitalize each word - words := strings.Split(s, "-") - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) - } - } - return strings.Join(words, "-") -} - // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { @@ -547,79 +151,22 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe score := 0 // IPRev (15 points) - if results.Iprev != nil { - switch results.Iprev.Result { - case api.Pass: - score += 15 - default: // fail, temperror, permerror - score += 0 - } - } + score += 15 * a.calculateIPRevScore(results) / 100 // SPF (25 points) - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 25 - case api.AuthResultResultNeutral, api.AuthResultResultNone: - score += 12 - case api.AuthResultResultSoftfail: - score += 4 - default: // fail, temperror, permerror - score += 0 - } - } + score += 25 * a.calculateSPFScore(results) / 100 - // DKIM (25 points) - at least one passing signature - if results.Dkim != nil && len(*results.Dkim) > 0 { - hasPass := false - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - hasPass = true - break - } - } - if hasPass { - score += 25 - } else { - // Has DKIM signatures but none passed - score += 10 - } - } + // DKIM (25 points) + score += 25 * a.calculateDKIMScore(results) / 100 // X-Google-DKIM (optional) - penalty if failed - if results.XGoogleDkim != nil { - switch results.XGoogleDkim.Result { - case api.AuthResultResultPass: - // pass: don't alter the score - default: // fail - score -= 12 - } - } + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 // DMARC (25 points) - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 25 - case api.AuthResultResultNone: - score += 10 - default: // fail - score += 0 - } - } + score += 25 * a.calculateDMARCScore(results) / 100 // BIMI (10 points) - if results.Bimi != nil { - switch results.Bimi.Result { - case api.AuthResultResultPass: - score += 10 - case api.AuthResultResultDeclined: - score += 5 - default: // fail - score += 0 - } - } + score += 10 * a.calculateBIMIScore(results) / 100 // Ensure score doesn't exceed 100 if score > 100 { diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go new file mode 100644 index 0000000..01b7505 --- /dev/null +++ b/pkg/analyzer/authentication_arc.go @@ -0,0 +1,183 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.ARCResult{} + + // Extract result (pass, fail, none) + re := regexp.MustCompile(`arc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.ARCResultResult(resultStr) + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) + + return result +} + +// parseARCHeaders parses ARC headers from email message +// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &api.ARCResult{ + Result: api.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = api.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = api.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = api.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go new file mode 100644 index 0000000..9269d70 --- /dev/null +++ b/pkg/analyzer/authentication_arc_test.go @@ -0,0 +1,150 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.ARCResultResult + }{ + { + name: "ARC pass", + part: "arc=pass", + expectedResult: api.ARCResultResultPass, + }, + { + name: "ARC fail", + part: "arc=fail", + expectedResult: api.ARCResultResultFail, + }, + { + name: "ARC none", + part: "arc=none", + expectedResult: api.ARCResultResultNone, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + }) + } +} + +func TestValidateARCChain(t *testing.T) { + tests := []struct { + name string + arcAuthResults []string + arcMessageSig []string + arcSeal []string + expectedValid bool + }{ + { + name: "Empty chain is valid", + arcAuthResults: []string{}, + arcMessageSig: []string{}, + arcSeal: []string{}, + expectedValid: true, + }, + { + name: "Valid chain with single hop", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + }, + expectedValid: true, + }, + { + name: "Valid chain with two hops", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=2; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=2; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=2; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: true, + }, + { + name: "Invalid chain - missing one header type", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{}, + expectedValid: false, + }, + { + name: "Invalid chain - non-sequential instances", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=3; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=3; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=3; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: false, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) + + if valid != tt.expectedValid { + t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) + } + }) + } +} diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go new file mode 100644 index 0000000..0d68281 --- /dev/null +++ b/pkg/analyzer/authentication_bimi.go @@ -0,0 +1,75 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { + if results.Bimi != nil { + switch results.Bimi.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultDeclined: + return 59 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go new file mode 100644 index 0000000..b1b5468 --- /dev/null +++ b/pkg/analyzer/authentication_bimi_test.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "BIMI pass with domain and selector", + part: "bimi=pass header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI with short form (d= and selector=)", + part: "bimi=pass d=example.com selector=v1", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: api.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go new file mode 100644 index 0000000..9ce0dd2 --- /dev/null +++ b/pkg/analyzer/authentication_dkim.go @@ -0,0 +1,115 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDKIMResult parses DKIM result from Authentication-Results +// Example: dkim=pass header.d=example.com header.s=selector1 +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + + return result +} + +// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header +func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { + var results []api.AuthResult + + // Get all DKIM-Signature headers + dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] + for _, dkimHeader := range dkimHeaders { + result := api.AuthResult{ + Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone + } + + // Extract domain (d=) + domainRe := regexp.MustCompile(`d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (s=) + selectorRe := regexp.MustCompile(`s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + details := "DKIM signature present (verification status unknown)" + result.Details = &details + + results = append(results, result) + } + + return results +} + +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { + // Expect at least one passing signature + if results.Dkim != nil && len(*results.Dkim) > 0 { + hasPass := false + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + hasPass = true + break + } + } + if hasPass { + return 100 + } else { + // Has DKIM signatures but none passed + return 20 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go new file mode 100644 index 0000000..0d00031 --- /dev/null +++ b/pkg/analyzer/authentication_dkim_test.go @@ -0,0 +1,328 @@ +// 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 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 TestParseLegacyDKIM(t *testing.T) { + tests := []struct { + name string + dkimSignatures []string + expectedCount int + expectedDomains []string + expectedSelector []string + }{ + { + name: "Single DKIM signature with domain and selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "Multiple DKIM signatures", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", + "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "example.com"}, + expectedSelector: []string{"selector1", "selector2"}, + }, + { + name: "DKIM signature with different domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", + }, + expectedCount: 1, + expectedDomains: []string{"mail.example.org"}, + expectedSelector: []string{"default"}, + }, + { + name: "DKIM signature with subdomain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", + }, + expectedCount: 1, + expectedDomains: []string{"newsletters.example.com"}, + expectedSelector: []string{"marketing"}, + }, + { + name: "Multiple signatures from different domains", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", + "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "relay.com"}, + expectedSelector: []string{"s1", "s2"}, + }, + { + name: "No DKIM signatures", + dkimSignatures: []string{}, + expectedCount: 0, + expectedDomains: []string{}, + expectedSelector: []string{}, + }, + { + name: "DKIM signature without selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{""}, + }, + { + name: "DKIM signature without domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; s=selector1; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{""}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with whitespace in parameters", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with multiline format", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with ed25519 algorithm", + dkimSignatures: []string{ + "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"ed25519"}, + }, + { + name: "Complex real-world DKIM signature", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", + }, + expectedCount: 1, + expectedDomains: []string{"google.com"}, + expectedSelector: []string{"20230601"}, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with DKIM-Signature headers + email := &EmailMessage{ + Header: make(map[string][]string), + } + if len(tt.dkimSignatures) > 0 { + email.Header["Dkim-Signature"] = tt.dkimSignatures + } + + results := analyzer.parseLegacyDKIM(email) + + // Check count + if len(results) != tt.expectedCount { + t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) + return + } + + // Check each result + for i, result := range results { + // All legacy DKIM results should have Result = none + if result.Result != api.AuthResultResultNone { + t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) + } + + // Check domain + if i < len(tt.expectedDomains) { + expectedDomain := tt.expectedDomains[i] + if expectedDomain != "" { + if result.Domain == nil { + t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) + } else if strings.TrimSpace(*result.Domain) != expectedDomain { + t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) + } + } + } + + // Check selector + if i < len(tt.expectedSelector) { + expectedSelector := tt.expectedSelector[i] + if expectedSelector != "" { + if result.Selector == nil { + t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) + } else if strings.TrimSpace(*result.Selector) != expectedSelector { + t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) + } + } + } + + // Check that Details is set + if result.Details == nil { + t.Errorf("Result[%d].Details = nil, expected non-nil", i) + } else { + expectedDetails := "DKIM signature present (verification status unknown)" + if *result.Details != expectedDetails { + t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) + } + } + } + }) + } +} + +func TestParseLegacyDKIM_Integration(t *testing.T) { + // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication + t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultNone { + t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { + t.Error("Expected domain to be 'example.com'") + } + }) + + t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + // Both Authentication-Results and DKIM-Signature headers + email.Header["Authentication-Results"] = []string{ + "mx.example.com; dkim=pass header.d=verified.com header.s=s1", + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultPass { + t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { + t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") + } + }) +} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go new file mode 100644 index 0000000..329a5c9 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc.go @@ -0,0 +1,68 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDMARCResult parses DMARC result from Authentication-Results +// Example: dmarc=pass action=none header.from=example.com +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dmarc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNone: + return 33 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go new file mode 100644 index 0000000..d7fda84 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -0,0 +1,69 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go new file mode 100644 index 0000000..6538cbb --- /dev/null +++ b/pkg/analyzer/authentication_iprev.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseIPRevResult parses IP reverse lookup result from Authentication-Results +// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { + result := &api.IPRevResult{} + + // Extract result (pass, fail, temperror, permerror, none) + re := regexp.MustCompile(`iprev=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.IPRevResultResult(resultStr) + } + + // Extract IP address (smtp.remote-ip or remote-ip) + ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) + if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { + ip := matches[1] + result.Ip = &ip + } + + // Extract hostname from parentheses + hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) + if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { + hostname := matches[1] + result.Hostname = &hostname + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { + if results.Iprev != nil { + switch results.Iprev.Result { + case api.Pass: + return 100 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go new file mode 100644 index 0000000..d0529b5 --- /dev/null +++ b/pkg/analyzer/authentication_iprev_test.go @@ -0,0 +1,225 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseIPRevResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass with IP and hostname", + part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev pass without smtp prefix", + part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", + expectedResult: api.Fail, + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: api.PtrTo("unknown.host.com"), + }, + { + name: "IPRev temperror", + part: "iprev=temperror smtp.remote-ip=203.0.113.1", + expectedResult: api.Temperror, + expectedIP: api.PtrTo("203.0.113.1"), + expectedHostname: nil, + }, + { + name: "IPRev permerror", + part: "iprev=permerror smtp.remote-ip=192.0.2.100", + expectedResult: api.Permerror, + expectedIP: api.PtrTo("192.0.2.100"), + expectedHostname: nil, + }, + { + name: "IPRev with IPv6", + part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("2001:db8::1"), + expectedHostname: api.PtrTo("ipv6.example.com"), + }, + { + name: "IPRev with subdomain hostname", + part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.50"), + expectedHostname: api.PtrTo("mail.subdomain.example.com"), + }, + { + name: "IPRev pass without parentheses", + part: "iprev=pass smtp.remote-ip=192.0.2.200", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.200"), + expectedHostname: nil, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseIPRevResult(tt.part) + + // Check result + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + // Check IP + if tt.expectedIP != nil { + if result.Ip == nil { + t.Errorf("IP = nil, want %v", *tt.expectedIP) + } else if *result.Ip != *tt.expectedIP { + t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) + } + } else { + if result.Ip != nil { + t.Errorf("IP = %v, want nil", *result.Ip) + } + } + + // Check hostname + if tt.expectedHostname != nil { + if result.Hostname == nil { + t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) + } else if *result.Hostname != *tt.expectedHostname { + t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) + } + } else { + if result.Hostname != nil { + t.Errorf("Hostname = %v, want nil", *result.Hostname) + } + } + + // Check details + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } + }) + } +} + +func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { + tests := []struct { + name string + header string + expectedIPRevResult *api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass in Authentication-Results", + header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev with other authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", + expectedIPRevResult: api.PtrTo(api.Fail), + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: nil, + }, + { + name: "No IPRev in header", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", + expectedIPRevResult: nil, + }, + { + name: "Multiple IPRev results - only first is parsed", + header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("first.com"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check IPRev + if tt.expectedIPRevResult != nil { + if results.Iprev == nil { + t.Errorf("Expected IPRev result, got nil") + } else { + if results.Iprev.Result != *tt.expectedIPRevResult { + t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) + } + if tt.expectedIP != nil { + if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { + var gotIP string + if results.Iprev.Ip != nil { + gotIP = *results.Iprev.Ip + } + t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) + } + } + if tt.expectedHostname != nil { + if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { + var gotHostname string + if results.Iprev.Hostname != nil { + gotHostname = *results.Iprev.Hostname + } + t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) + } + } + } + } else { + if results.Iprev != nil { + t.Errorf("Expected no IPRev result, got %+v", results.Iprev) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go new file mode 100644 index 0000000..479c325 --- /dev/null +++ b/pkg/analyzer/authentication_spf.go @@ -0,0 +1,105 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseSPFResult parses SPF result from Authentication-Results +// Example: spf=pass smtp.mailfrom=sender@example.com +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`spf=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + result := &api.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = api.AuthResultResult(resultStr) + } + + result.Details = &receivedSPF + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNeutral, api.AuthResultResultNone: + return 50 + case api.AuthResultResultSoftfail: + return 17 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go new file mode 100644 index 0000000..7a84c49 --- /dev/null +++ b/pkg/analyzer/authentication_spf_test.go @@ -0,0 +1,212 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseLegacySPF(t *testing.T) { + tests := []struct { + name string + receivedSPF string + expectedResult api.AuthResultResult + expectedDomain *string + expectNil bool + }{ + { + name: "SPF pass with envelope-from", + receivedSPF: `pass + (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) + receiver=mx.receiver.com; + identity=mailfrom; + envelope-from="user@example.com"; + helo=smtp.example.com; + client-ip=192.0.2.10`, + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("example.com"), + }, + { + name: "SPF fail with sender", + receivedSPF: `fail + (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) + receiver=mx.receiver.com; + identity=mailfrom; + sender="sender@test.com"; + helo=smtp.test.com; + client-ip=192.0.2.20`, + expectedResult: api.AuthResultResultFail, + expectedDomain: api.PtrTo("test.com"), + }, + { + name: "SPF softfail", + receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: api.PtrTo("example.org"), + }, + { + name: "SPF neutral", + receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: api.PtrTo("domain.net"), + }, + { + name: "SPF none", + receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", + expectedResult: api.AuthResultResultNone, + expectedDomain: api.PtrTo("company.io"), + }, + { + name: "SPF temperror", + receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", + expectedResult: api.AuthResultResultTemperror, + expectedDomain: api.PtrTo("shop.example"), + }, + { + name: "SPF permerror", + receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", + expectedResult: api.AuthResultResultPermerror, + expectedDomain: api.PtrTo("invalid.test"), + }, + { + name: "SPF pass without domain extraction", + receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", + expectedResult: api.AuthResultResultPass, + expectedDomain: nil, + }, + { + name: "Empty Received-SPF header", + receivedSPF: "", + expectNil: true, + }, + { + name: "SPF with unquoted envelope-from", + receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("mail.example.net"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with Received-SPF header + email := &EmailMessage{ + Header: make(map[string][]string), + } + if tt.receivedSPF != "" { + email.Header["Received-Spf"] = []string{tt.receivedSPF} + } + + result := analyzer.parseLegacySPF(email) + + if tt.expectNil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result, got nil") + } + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if tt.expectedDomain != nil { + if result.Domain == nil { + t.Errorf("Domain = nil, want %v", *tt.expectedDomain) + } else if *result.Domain != *tt.expectedDomain { + t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) + } + } else { + if result.Domain != nil { + t.Errorf("Domain = %v, want nil", *result.Domain) + } + } + + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } else if *result.Details != tt.receivedSPF { + t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) + } + }) + } +} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index f0b7163..63f9e2d 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -22,230 +22,11 @@ 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 TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string @@ -332,42 +113,6 @@ func TestGetAuthenticationScore(t *testing.T) { } } -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 TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string @@ -691,713 +436,3 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { } }) } - -func TestParseLegacySPF(t *testing.T) { - tests := []struct { - name string - receivedSPF string - expectedResult api.AuthResultResult - expectedDomain *string - expectNil bool - }{ - { - name: "SPF pass with envelope-from", - receivedSPF: `pass - (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) - receiver=mx.receiver.com; - identity=mailfrom; - envelope-from="user@example.com"; - helo=smtp.example.com; - client-ip=192.0.2.10`, - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("example.com"), - }, - { - name: "SPF fail with sender", - receivedSPF: `fail - (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) - receiver=mx.receiver.com; - identity=mailfrom; - sender="sender@test.com"; - helo=smtp.test.com; - client-ip=192.0.2.20`, - expectedResult: api.AuthResultResultFail, - expectedDomain: api.PtrTo("test.com"), - }, - { - name: "SPF softfail", - receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: api.PtrTo("example.org"), - }, - { - name: "SPF neutral", - receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: api.PtrTo("domain.net"), - }, - { - name: "SPF none", - receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", - expectedResult: api.AuthResultResultNone, - expectedDomain: api.PtrTo("company.io"), - }, - { - name: "SPF temperror", - receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", - expectedResult: api.AuthResultResultTemperror, - expectedDomain: api.PtrTo("shop.example"), - }, - { - name: "SPF permerror", - receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", - expectedResult: api.AuthResultResultPermerror, - expectedDomain: api.PtrTo("invalid.test"), - }, - { - name: "SPF pass without domain extraction", - receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: api.AuthResultResultPass, - expectedDomain: nil, - }, - { - name: "Empty Received-SPF header", - receivedSPF: "", - expectNil: true, - }, - { - name: "SPF with unquoted envelope-from", - receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("mail.example.net"), - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with Received-SPF header - email := &EmailMessage{ - Header: make(map[string][]string), - } - if tt.receivedSPF != "" { - email.Header["Received-Spf"] = []string{tt.receivedSPF} - } - - result := analyzer.parseLegacySPF(email) - - if tt.expectNil { - if result != nil { - t.Errorf("Expected nil result, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("Expected non-nil result, got nil") - } - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - if tt.expectedDomain != nil { - if result.Domain == nil { - t.Errorf("Domain = nil, want %v", *tt.expectedDomain) - } else if *result.Domain != *tt.expectedDomain { - t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) - } - } else { - if result.Domain != nil { - t.Errorf("Domain = %v, want nil", *result.Domain) - } - } - - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } else if *result.Details != tt.receivedSPF { - t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) - } - }) - } -} - -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 TestParseLegacyDKIM(t *testing.T) { - tests := []struct { - name string - dkimSignatures []string - expectedCount int - expectedDomains []string - expectedSelector []string - }{ - { - name: "Single DKIM signature with domain and selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "Multiple DKIM signatures", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", - "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "example.com"}, - expectedSelector: []string{"selector1", "selector2"}, - }, - { - name: "DKIM signature with different domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", - }, - expectedCount: 1, - expectedDomains: []string{"mail.example.org"}, - expectedSelector: []string{"default"}, - }, - { - name: "DKIM signature with subdomain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", - }, - expectedCount: 1, - expectedDomains: []string{"newsletters.example.com"}, - expectedSelector: []string{"marketing"}, - }, - { - name: "Multiple signatures from different domains", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", - "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "relay.com"}, - expectedSelector: []string{"s1", "s2"}, - }, - { - name: "No DKIM signatures", - dkimSignatures: []string{}, - expectedCount: 0, - expectedDomains: []string{}, - expectedSelector: []string{}, - }, - { - name: "DKIM signature without selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{""}, - }, - { - name: "DKIM signature without domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; s=selector1; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{""}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with whitespace in parameters", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with multiline format", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with ed25519 algorithm", - dkimSignatures: []string{ - "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"ed25519"}, - }, - { - name: "Complex real-world DKIM signature", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", - }, - expectedCount: 1, - expectedDomains: []string{"google.com"}, - expectedSelector: []string{"20230601"}, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with DKIM-Signature headers - email := &EmailMessage{ - Header: make(map[string][]string), - } - if len(tt.dkimSignatures) > 0 { - email.Header["Dkim-Signature"] = tt.dkimSignatures - } - - results := analyzer.parseLegacyDKIM(email) - - // Check count - if len(results) != tt.expectedCount { - t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) - return - } - - // Check each result - for i, result := range results { - // All legacy DKIM results should have Result = none - if result.Result != api.AuthResultResultNone { - t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) - } - - // Check domain - if i < len(tt.expectedDomains) { - expectedDomain := tt.expectedDomains[i] - if expectedDomain != "" { - if result.Domain == nil { - t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) - } else if strings.TrimSpace(*result.Domain) != expectedDomain { - t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) - } - } - } - - // Check selector - if i < len(tt.expectedSelector) { - expectedSelector := tt.expectedSelector[i] - if expectedSelector != "" { - if result.Selector == nil { - t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) - } else if strings.TrimSpace(*result.Selector) != expectedSelector { - t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) - } - } - } - - // Check that Details is set - if result.Details == nil { - t.Errorf("Result[%d].Details = nil, expected non-nil", i) - } else { - expectedDetails := "DKIM signature present (verification status unknown)" - if *result.Details != expectedDetails { - t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) - } - } - } - }) - } -} - -func TestParseLegacyDKIM_Integration(t *testing.T) { - // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication - t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultNone { - t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { - t.Error("Expected domain to be 'example.com'") - } - }) - - t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - // Both Authentication-Results and DKIM-Signature headers - email.Header["Authentication-Results"] = []string{ - "mx.example.com; dkim=pass header.d=verified.com header.s=s1", - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { - t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { - t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") - } - }) -} - -func TestParseIPRevResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.IPRevResultResult - expectedIP *string - expectedHostname *string - }{ - { - name: "IPRev pass with IP and hostname", - part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), - }, - { - name: "IPRev pass without smtp prefix", - part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), - }, - { - name: "IPRev fail", - part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", - expectedResult: api.Fail, - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: api.PtrTo("unknown.host.com"), - }, - { - name: "IPRev temperror", - part: "iprev=temperror smtp.remote-ip=203.0.113.1", - expectedResult: api.Temperror, - expectedIP: api.PtrTo("203.0.113.1"), - expectedHostname: nil, - }, - { - name: "IPRev permerror", - part: "iprev=permerror smtp.remote-ip=192.0.2.100", - expectedResult: api.Permerror, - expectedIP: api.PtrTo("192.0.2.100"), - expectedHostname: nil, - }, - { - name: "IPRev with IPv6", - part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("2001:db8::1"), - expectedHostname: api.PtrTo("ipv6.example.com"), - }, - { - name: "IPRev with subdomain hostname", - part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.50"), - expectedHostname: api.PtrTo("mail.subdomain.example.com"), - }, - { - name: "IPRev pass without parentheses", - part: "iprev=pass smtp.remote-ip=192.0.2.200", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.200"), - expectedHostname: nil, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseIPRevResult(tt.part) - - // Check result - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - // Check IP - if tt.expectedIP != nil { - if result.Ip == nil { - t.Errorf("IP = nil, want %v", *tt.expectedIP) - } else if *result.Ip != *tt.expectedIP { - t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) - } - } else { - if result.Ip != nil { - t.Errorf("IP = %v, want nil", *result.Ip) - } - } - - // Check hostname - if tt.expectedHostname != nil { - if result.Hostname == nil { - t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) - } else if *result.Hostname != *tt.expectedHostname { - t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) - } - } else { - if result.Hostname != nil { - t.Errorf("Hostname = %v, want nil", *result.Hostname) - } - } - - // Check details - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } - }) - } -} - -func TestParseXGoogleDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "x-google-dkim pass with domain", - part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", - expectedResult: api.AuthResultResultPass, - expectedDomain: "1e100.net", - }, - { - name: "x-google-dkim pass with short form", - part: "x-google-dkim=pass d=gmail.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "gmail.com", - }, - { - name: "x-google-dkim fail", - part: "x-google-dkim=fail header.d=example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "x-google-dkim with minimal info", - part: "x-google-dkim=pass", - expectedResult: api.AuthResultResultPass, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseXGoogleDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if tt.expectedDomain != "" { - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - } - }) - } -} - -func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { - tests := []struct { - name string - header string - expectedIPRevResult *api.IPRevResultResult - expectedIP *string - expectedHostname *string - }{ - { - name: "IPRev pass in Authentication-Results", - header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), - }, - { - name: "IPRev with other authentication methods", - header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), - }, - { - name: "IPRev fail", - header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", - expectedIPRevResult: api.PtrTo(api.Fail), - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: nil, - }, - { - name: "No IPRev in header", - header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", - expectedIPRevResult: nil, - }, - { - name: "Multiple IPRev results - only first is parsed", - header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("first.com"), - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(tt.header, results) - - // Check IPRev - if tt.expectedIPRevResult != nil { - if results.Iprev == nil { - t.Errorf("Expected IPRev result, got nil") - } else { - if results.Iprev.Result != *tt.expectedIPRevResult { - t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) - } - if tt.expectedIP != nil { - if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { - var gotIP string - if results.Iprev.Ip != nil { - gotIP = *results.Iprev.Ip - } - t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) - } - } - if tt.expectedHostname != nil { - if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { - var gotHostname string - if results.Iprev.Hostname != nil { - gotHostname = *results.Iprev.Hostname - } - t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) - } - } - } - } else { - if results.Iprev != nil { - t.Errorf("Expected no IPRev result, got %+v", results.Iprev) - } - } - }) - } -} diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go new file mode 100644 index 0000000..4bba469 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -0,0 +1,73 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results +// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-google-dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case api.AuthResultResultPass: + // pass: don't alter the score + default: // fail + return -100 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go new file mode 100644 index 0000000..be29a08 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -0,0 +1,83 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "x-google-dkim pass with domain", + part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", + expectedResult: api.AuthResultResultPass, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: api.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +} From 3ea958b2fdacfa4ff54854a2baade271fc032130 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 14:24:35 +0700 Subject: [PATCH 088/256] Split dns.go in one file per check --- pkg/analyzer/dns.go | 769 +-------------------------------- pkg/analyzer/dns_bimi.go | 114 +++++ pkg/analyzer/dns_bimi_test.go | 128 ++++++ pkg/analyzer/dns_dkim.go | 116 +++++ pkg/analyzer/dns_dkim_test.go | 72 +++ pkg/analyzer/dns_dmarc.go | 256 +++++++++++ pkg/analyzer/dns_dmarc_test.go | 343 +++++++++++++++ pkg/analyzer/dns_fcr.go | 94 ++++ pkg/analyzer/dns_mx.go | 115 +++++ pkg/analyzer/dns_spf.go | 268 ++++++++++++ pkg/analyzer/dns_spf_test.go | 137 ++++++ pkg/analyzer/dns_test.go | 578 ------------------------- 12 files changed, 1649 insertions(+), 1341 deletions(-) create mode 100644 pkg/analyzer/dns_bimi.go create mode 100644 pkg/analyzer/dns_bimi_test.go create mode 100644 pkg/analyzer/dns_dkim.go create mode 100644 pkg/analyzer/dns_dkim_test.go create mode 100644 pkg/analyzer/dns_dmarc.go create mode 100644 pkg/analyzer/dns_dmarc_test.go create mode 100644 pkg/analyzer/dns_fcr.go create mode 100644 pkg/analyzer/dns_mx.go create mode 100644 pkg/analyzer/dns_spf.go create mode 100644 pkg/analyzer/dns_spf_test.go diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index e75b3ac..c76359c 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -22,11 +22,7 @@ package analyzer import ( - "context" - "fmt" "net" - "regexp" - "strings" "time" "git.happydns.org/happyDeliver/internal/api" @@ -128,570 +124,6 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic return results } -// checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - mxRecords, err := d.resolver.LookupMX(ctx, domain) - if err != nil { - return &[]api.MXRecord{ - { - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), - }, - } - } - - if len(mxRecords) == 0 { - return &[]api.MXRecord{ - { - Valid: false, - Error: api.PtrTo("No MX records found"), - }, - } - } - - var results []api.MXRecord - for _, mx := range mxRecords { - results = append(results, api.MXRecord{ - Host: mx.Host, - Priority: mx.Pref, - Valid: true, - }) - } - - return &results -} - -// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives -func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { - visited := make(map[string]bool) - return d.resolveSPFRecords(domain, visited, 0) -} - -// resolveSPFRecords recursively resolves SPF records including include: directives -func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { - const maxDepth = 10 // Prevent infinite recursion - - if depth > maxDepth { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo("Maximum SPF include depth exceeded"), - }, - } - } - - // Prevent circular references - if visited[domain] { - return &[]api.SPFRecord{} - } - visited[domain] = true - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, domain) - if err != nil { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), - }, - } - } - - // Find SPF record (starts with "v=spf1") - var spfRecord string - spfCount := 0 - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=spf1") { - spfRecord = txt - spfCount++ - } - } - - if spfCount == 0 { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo("No SPF record found"), - }, - } - } - - var results []api.SPFRecord - - if spfCount > 1 { - results = append(results, api.SPFRecord{ - Domain: &domain, - Record: &spfRecord, - Valid: false, - Error: api.PtrTo("Multiple SPF records found (RFC violation)"), - }) - return &results - } - - // Basic validation - valid := d.validateSPF(spfRecord) - - // Extract the "all" mechanism qualifier - var allQualifier *api.SPFRecordAllQualifier - var errMsg *string - - if !valid { - errMsg = api.PtrTo("SPF record appears malformed") - } else { - // Extract qualifier from the "all" mechanism - if strings.HasSuffix(spfRecord, " -all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) - } else if strings.HasSuffix(spfRecord, " ~all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) - } else if strings.HasSuffix(spfRecord, " +all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) - } else if strings.HasSuffix(spfRecord, " ?all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) - } else if strings.HasSuffix(spfRecord, " all") { - // Implicit + qualifier (default) - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) - } - } - - results = append(results, api.SPFRecord{ - Domain: &domain, - Record: &spfRecord, - Valid: valid, - AllQualifier: allQualifier, - Error: errMsg, - }) - - // Check for redirect= modifier first (it replaces the entire SPF policy) - redirectDomain := d.extractSPFRedirect(spfRecord) - if redirectDomain != "" { - // redirect= replaces the current domain's policy entirely - // Only follow if no other mechanisms matched (per RFC 7208) - redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) - if redirectRecords != nil { - results = append(results, *redirectRecords...) - } - return &results - } - - // Extract and resolve include: directives - includes := d.extractSPFIncludes(spfRecord) - for _, includeDomain := range includes { - includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) - if includedRecords != nil { - results = append(results, *includedRecords...) - } - } - - return &results -} - -// extractSPFIncludes extracts all include: domains from an SPF record -func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { - var includes []string - re := regexp.MustCompile(`include:([^\s]+)`) - matches := re.FindAllStringSubmatch(record, -1) - for _, match := range matches { - if len(match) > 1 { - includes = append(includes, match[1]) - } - } - return includes -} - -// extractSPFRedirect extracts the redirect= domain from an SPF record -// The redirect= modifier replaces the current domain's SPF policy with that of the target domain -func (d *DNSAnalyzer) extractSPFRedirect(record string) string { - re := regexp.MustCompile(`redirect=([^\s]+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "" -} - -// validateSPF performs basic SPF record validation -func (d *DNSAnalyzer) validateSPF(record string) bool { - // Must start with v=spf1 - if !strings.HasPrefix(record, "v=spf1") { - return false - } - - // Check for redirect= modifier (which replaces the need for an 'all' mechanism) - if strings.Contains(record, "redirect=") { - return true - } - - // Check for common syntax issues - // Should have a final mechanism (all, +all, -all, ~all, ?all) - validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} - hasValidEnding := false - for _, ending := range validEndings { - if strings.HasSuffix(record, ending) { - hasValidEnding = true - break - } - } - - return hasValidEnding -} - -// hasSPFStrictFail checks if SPF record has strict -all mechanism -func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { - return strings.HasSuffix(record, " -all") -} - -// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { - // DKIM records are at: selector._domainkey.domain - dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) - if err != nil { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), - } - } - - if len(txtRecords) == 0 { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo("No DKIM record found"), - } - } - - // Concatenate all TXT record parts (DKIM can be split) - dkimRecord := strings.Join(txtRecords, "") - - // Basic validation - should contain "v=DKIM1" and "p=" (public key) - if !d.validateDKIM(dkimRecord) { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Record: api.PtrTo(dkimRecord), - Valid: false, - Error: api.PtrTo("DKIM record appears malformed"), - } - } - - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Record: &dkimRecord, - Valid: true, - } -} - -// validateDKIM performs basic DKIM record validation -func (d *DNSAnalyzer) validateDKIM(record string) bool { - // Should contain p= tag (public key) - if !strings.Contains(record, "p=") { - return false - } - - // Often contains v=DKIM1 but not required - // If v= is present, it should be DKIM1 - if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { - return false - } - - return true -} - -// checkapi.DMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { - // DMARC records are at: _dmarc.domain - dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) - if err != nil { - return &api.DMARCRecord{ - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), - } - } - - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break - } - } - - if dmarcRecord == "" { - return &api.DMARCRecord{ - Valid: false, - Error: api.PtrTo("No DMARC record found"), - } - } - - // Extract policy - policy := d.extractDMARCPolicy(dmarcRecord) - - // Extract subdomain policy - subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) - - // Extract percentage - percentage := d.extractDMARCPercentage(dmarcRecord) - - // Extract alignment modes - spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) - dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) - - // Basic validation - if !d.validateDMARC(dmarcRecord) { - return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: false, - Error: api.PtrTo("DMARC record appears malformed"), - } - } - - return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: true, - } -} - -// extractDMARCPolicy extracts the policy from a DMARC record -func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { - // Look for p=none, p=quarantine, or p=reject - re := regexp.MustCompile(`p=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "unknown" -} - -// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { - // Look for aspf=s (strict) or aspf=r (relaxed) - re := regexp.MustCompile(`aspf=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) - } - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) - } - // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) -} - -// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { - // Look for adkim=s (strict) or adkim=r (relaxed) - re := regexp.MustCompile(`adkim=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) - } - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) - } - // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) -} - -// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record -// Returns the sp tag value or nil if not specified (defaults to main policy) -func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { - // Look for sp=none, sp=quarantine, or sp=reject - re := regexp.MustCompile(`sp=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) - } - // If sp is not specified, it defaults to the main policy (p tag) - // Return nil to indicate it's using the default - return nil -} - -// extractDMARCPercentage extracts the percentage from a DMARC record -// Returns the pct tag value or nil if not specified (defaults to 100) -func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { - // Look for pct= - re := regexp.MustCompile(`pct=(\d+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - // Convert string to int - var pct int - fmt.Sscanf(matches[1], "%d", &pct) - // Validate range (0-100) - if pct >= 0 && pct <= 100 { - return &pct - } - } - // Default is 100 if not specified - return nil -} - -// validateDMARC performs basic DMARC record validation -func (d *DNSAnalyzer) validateDMARC(record string) bool { - // Must start with v=DMARC1 - if !strings.HasPrefix(record, "v=DMARC1") { - return false - } - - // Must have a policy tag - if !strings.Contains(record, "p=") { - return false - } - - return true -} - -// checkBIMIRecord looks up and validates BIMI record for a domain and selector -func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { - // BIMI records are at: selector._bimi.domain - bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) - if err != nil { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), - } - } - - if len(txtRecords) == 0 { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo("No BIMI record found"), - } - } - - // Concatenate all TXT record parts (BIMI can be split) - bimiRecord := strings.Join(txtRecords, "") - - // Extract logo URL and VMC URL - logoURL := d.extractBIMITag(bimiRecord, "l") - vmcURL := d.extractBIMITag(bimiRecord, "a") - - // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) - if !d.validateBIMI(bimiRecord) { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Record: &bimiRecord, - LogoUrl: &logoURL, - VmcUrl: &vmcURL, - Valid: false, - Error: api.PtrTo("BIMI record appears malformed"), - } - } - - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Record: &bimiRecord, - LogoUrl: &logoURL, - VmcUrl: &vmcURL, - Valid: true, - } -} - -// extractBIMITag extracts a tag value from a BIMI record -func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { - // Look for tag=value pattern - re := regexp.MustCompile(tag + `=([^;]+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return strings.TrimSpace(matches[1]) - } - return "" -} - -// validateBIMI performs basic BIMI record validation -func (d *DNSAnalyzer) validateBIMI(record string) bool { - // Must start with v=BIMI1 - if !strings.HasPrefix(record, "v=BIMI1") { - return false - } - - // Must have a logo URL tag (l=) - if !strings.Contains(record, "l=") { - return false - } - - return true -} - -// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) -// Returns PTR hostnames and their corresponding forward-resolved IPs -func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - // Perform reverse DNS lookup (PTR) - ptrNames, err := d.resolver.LookupAddr(ctx, ip) - if err != nil || len(ptrNames) == 0 { - return nil, nil - } - - var forwardIPs []string - seenIPs := make(map[string]bool) - - // For each PTR record, perform forward DNS lookup (A/AAAA) - for _, ptrName := range ptrNames { - // Look up A records - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - aRecords, err := d.resolver.LookupHost(ctx, ptrName) - cancel() - - if err == nil { - for _, forwardIP := range aRecords { - if !seenIPs[forwardIP] { - forwardIPs = append(forwardIPs, forwardIP) - seenIPs[forwardIP] = true - } - } - } - } - - return ptrNames, forwardIPs -} - // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better // senderIP is the original sender IP address used for FCrDNS verification @@ -703,210 +135,21 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string score := 0 // PTR and Forward DNS: 20 points - // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability - if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { - // 10 points for having PTR records - score += 10 - - if len(*results.PtrRecords) > 1 { - // Penalty has it's bad to have multiple PTR records - score -= 3 - } - - // Additional 10 points for forward-confirmed reverse DNS (FCrDNS) - // This means the PTR hostname resolves back to IPs that include the original sender IP - if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { - // Verify that the sender IP is in the list of forward-resolved IPs - fcrDnsValid := false - for _, forwardIP := range *results.PtrForwardRecords { - if forwardIP == senderIP { - fcrDnsValid = true - break - } - } - if fcrDnsValid { - score += 10 - } - } - } + score += 20 * d.calculatePTRScore(results, senderIP) / 100 // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) - // Having valid MX records is critical for email deliverability - // From domain MX records (10 points) - needed for replies - if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { - hasValidFromMX := false - for _, mx := range *results.FromMxRecords { - if mx.Valid { - hasValidFromMX = true - break - } - } - if hasValidFromMX { - score += 10 - } - } - - // Return-Path domain MX records (10 points) - needed for bounces - if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { - hasValidRpMX := false - for _, mx := range *results.RpMxRecords { - if mx.Valid { - hasValidRpMX = true - break - } - } - if hasValidRpMX { - score += 10 - } - } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { - // If Return-Path domain is different but has no MX records, it's a problem - // Don't deduct points if RP domain is same as From domain (already checked) - } else { - // If Return-Path is same as From domain, give full 10 points for RP MX - if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { - hasValidFromMX := false - for _, mx := range *results.FromMxRecords { - if mx.Valid { - hasValidFromMX = true - break - } - } - if hasValidFromMX { - score += 10 - } - } - } + score += 20 * d.calculateMXScore(results) / 100 // SPF Records: 20 points - // SPF is essential for email authentication - if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { - // Find the main SPF record by skipping redirects - // Loop through records to find the last redirect or the first non-redirect - mainSPFIndex := 0 - for i := 0; i < len(*results.SpfRecords); i++ { - spfRecord := (*results.SpfRecords)[i] - if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { - // This is a redirect, check if there's a next record - if i+1 < len(*results.SpfRecords) { - mainSPFIndex = i + 1 - } else { - // Redirect exists but no target record found - break - } - } else { - // Found a non-redirect record - mainSPFIndex = i - break - } - } - - mainSPF := (*results.SpfRecords)[mainSPFIndex] - if mainSPF.Valid { - // Full points for valid SPF - score += 15 - - // Deduct points based on the all mechanism qualifier - if mainSPF.AllQualifier != nil { - switch *mainSPF.AllQualifier { - case "-": - // Strict fail - no deduction, this is the recommended policy - score += 5 - case "~": - // Softfail - moderate penalty - case "+", "?": - // Pass/neutral - severe penalty - score -= 5 - } - } else { - // No 'all' mechanism qualifier extracted - severe penalty - score -= 5 - } - } else if mainSPF.Record != nil { - // Partial credit if SPF record exists but has issues - score += 5 - } - } + score += 20 * d.calculateSPFScore(results) / 100 // DKIM Records: 20 points - // DKIM provides strong email authentication - if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { - hasValidDKIM := false - for _, dkim := range *results.DkimRecords { - if dkim.Valid { - hasValidDKIM = true - break - } - } - if hasValidDKIM { - score += 20 - } else { - // Partial credit if DKIM record exists but has issues - score += 5 - } - } + score += 20 * d.calculateDKIMScore(results) / 100 // DMARC Record: 20 points - // DMARC ties SPF and DKIM together and provides policy - if results.DmarcRecord != nil { - if results.DmarcRecord.Valid { - score += 10 - // Bonus points for stricter policies - if results.DmarcRecord.Policy != nil { - switch *results.DmarcRecord.Policy { - case "reject": - // Strictest policy - full points already awarded - score += 5 - case "quarantine": - // Good policy - no deduction - case "none": - // Weakest policy - deduct 5 points - score -= 5 - } - } - // Bonus points for strict alignment modes (2 points each) - if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { - score += 1 - } - if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { - score += 1 - } - // Subdomain policy scoring (sp tag) - // +3 for stricter or equal subdomain policy, -3 for weaker - if results.DmarcRecord.SubdomainPolicy != nil { - mainPolicy := string(*results.DmarcRecord.Policy) - subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + score += 20 * d.calculateDMARCScore(results) / 100 - // Policy strength: none < quarantine < reject - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - - mainStrength := policyStrength[mainPolicy] - subStrength := policyStrength[subPolicy] - - if subStrength >= mainStrength { - // Subdomain policy is equal or stricter - score += 3 - } else { - // Subdomain policy is weaker - score -= 3 - } - } else { - // No sp tag means subdomains inherit main policy (good default) - score += 3 - } - // Percentage scoring (pct tag) - // Apply the percentage on the current score - if results.DmarcRecord.Percentage != nil { - pct := *results.DmarcRecord.Percentage - - score = score * pct / 100 - } - } else if results.DmarcRecord.Record != nil { - // Partial credit if DMARC record exists but has issues - score += 5 - } - } - - // BIMI Record: 5 bonus points + // BIMI Record // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go new file mode 100644 index 0000000..44240e9 --- /dev/null +++ b/pkg/analyzer/dns_bimi.go @@ -0,0 +1,114 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No BIMI record found"), + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: false, + Error: api.PtrTo("BIMI record appears malformed"), + } + } + + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} diff --git a/pkg/analyzer/dns_bimi_test.go b/pkg/analyzer/dns_bimi_test.go new file mode 100644 index 0000000..cf7df83 --- /dev/null +++ b/pkg/analyzer/dns_bimi_test.go @@ -0,0 +1,128 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go new file mode 100644 index 0000000..7ac858d --- /dev/null +++ b/pkg/analyzer/dns_dkim.go @@ -0,0 +1,116 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { + // DKIM records are at: selector._domainkey.domain + dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) + if err != nil { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No DKIM record found"), + } + } + + // Concatenate all TXT record parts (DKIM can be split) + dkimRecord := strings.Join(txtRecords, "") + + // Basic validation - should contain "v=DKIM1" and "p=" (public key) + if !d.validateDKIM(dkimRecord) { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: api.PtrTo(dkimRecord), + Valid: false, + Error: api.PtrTo("DKIM record appears malformed"), + } + } + + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: &dkimRecord, + Valid: true, + } +} + +// validateDKIM performs basic DKIM record validation +func (d *DNSAnalyzer) validateDKIM(record string) bool { + // Should contain p= tag (public key) + if !strings.Contains(record, "p=") { + return false + } + + // Often contains v=DKIM1 but not required + // If v= is present, it should be DKIM1 + if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) { + // DKIM provides strong email authentication + if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { + hasValidDKIM := false + for _, dkim := range *results.DkimRecords { + if dkim.Valid { + hasValidDKIM = true + break + } + } + if hasValidDKIM { + score += 100 + } else { + // Partial credit if DKIM record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go new file mode 100644 index 0000000..8d94d20 --- /dev/null +++ b/pkg/analyzer/dns_dkim_test.go @@ -0,0 +1,72 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestValidateDKIM(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DKIM with version", + record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Valid DKIM without version", + record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Invalid DKIM - no public key", + record: "v=DKIM1; k=rsa", + expected: false, + }, + { + name: "Invalid DKIM - wrong version", + record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: false, + }, + { + name: "Invalid DKIM - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDKIM(tt.record) + if result != tt.expected { + t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go new file mode 100644 index 0000000..3b73ecc --- /dev/null +++ b/pkg/analyzer/dns_dmarc.go @@ -0,0 +1,256 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { + // DMARC records are at: _dmarc.domain + dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) + if err != nil { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + + // Find DMARC record (starts with "v=DMARC1") + var dmarcRecord string + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + dmarcRecord = txt + break + } + } + + if dmarcRecord == "" { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo("No DMARC record found"), + } + } + + // Extract policy + policy := d.extractDMARCPolicy(dmarcRecord) + + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + + // Basic validation + if !d.validateDMARC(dmarcRecord) { + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: api.PtrTo("DMARC record appears malformed"), + } + } + + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, + } +} + +// extractDMARCPolicy extracts the policy from a DMARC record +func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { + // Look for p=none, p=quarantine, or p=reject + re := regexp.MustCompile(`p=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { + // Look for aspf=s (strict) or aspf=r (relaxed) + re := regexp.MustCompile(`aspf=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { + // Look for adkim=s (strict) or adkim=r (relaxed) + re := regexp.MustCompile(`adkim=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) +} + +// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record +// Returns the sp tag value or nil if not specified (defaults to main policy) +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { + // Look for sp=none, sp=quarantine, or sp=reject + re := regexp.MustCompile(`sp=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + +// validateDMARC performs basic DMARC record validation +func (d *DNSAnalyzer) validateDMARC(record string) bool { + // Must start with v=DMARC1 + if !strings.HasPrefix(record, "v=DMARC1") { + return false + } + + // Must have a policy tag + if !strings.Contains(record, "p=") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) { + // DMARC ties SPF and DKIM together and provides policy + if results.DmarcRecord != nil { + if results.DmarcRecord.Valid { + score += 50 + // Bonus points for stricter policies + if results.DmarcRecord.Policy != nil { + switch *results.DmarcRecord.Policy { + case "reject": + // Strictest policy - full points already awarded + score += 25 + case "quarantine": + // Good policy - no deduction + case "none": + // Weakest policy - deduct 5 points + score -= 25 + } + } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + score += 5 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + score += 5 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 15 + } else { + // Subdomain policy is weaker + score -= 15 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 15 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } + } else if results.DmarcRecord.Record != nil { + // Partial credit if DMARC record exists but has issues + score += 20 + } + } + + return +} diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go new file mode 100644 index 0000000..0868e48 --- /dev/null +++ b/pkg/analyzer/dns_dmarc_test.go @@ -0,0 +1,343 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestExtractDMARCPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy string + }{ + { + name: "Policy none", + record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + expectedPolicy: "none", + }, + { + name: "Policy quarantine", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPolicy: "quarantine", + }, + { + name: "Policy reject", + record: "v=DMARC1; p=reject; sp=reject", + expectedPolicy: "reject", + }, + { + name: "No policy", + record: "v=DMARC1", + expectedPolicy: "unknown", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPolicy(tt.record) + if result != tt.expectedPolicy { + t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + } + }) + } +} + +func TestValidateDMARC(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DMARC", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Valid DMARC minimal", + record: "v=DMARC1; p=none", + expected: true, + }, + { + name: "Invalid DMARC - no version", + record: "p=quarantine", + expected: false, + }, + { + name: "Invalid DMARC - no policy", + record: "v=DMARC1", + expected: false, + }, + { + name: "Invalid DMARC - wrong version", + record: "v=DMARC2; p=reject", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDMARC(tt.record) + if result != tt.expected { + t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractDMARCSPFAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", + }, + { + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", + }, + { + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCDKIMAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", + }, + { + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: api.PtrTo("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: api.PtrTo("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: api.PtrTo("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: api.PtrTo("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + +func TestExtractDMARCPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: api.PtrTo(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: api.PtrTo(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: api.PtrTo(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: api.PtrTo(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: api.PtrTo(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPercentage(tt.record) + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go new file mode 100644 index 0000000..f90e5dc --- /dev/null +++ b/pkg/analyzer/dns_fcr.go @@ -0,0 +1,94 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + +// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability +func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) { + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 50 points for having PTR records + score += 50 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 15 + } + + // Additional 50 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go new file mode 100644 index 0000000..68e55b5 --- /dev/null +++ b/pkg/analyzer/dns_mx.go @@ -0,0 +1,115 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkMXRecords looks up MX records for a domain +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + mxRecords, err := d.resolver.LookupMX(ctx, domain) + if err != nil { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + }, + } + } + + if len(mxRecords) == 0 { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo("No MX records found"), + }, + } + } + + var results []api.MXRecord + for _, mx := range mxRecords { + results = append(results, api.MXRecord{ + Host: mx.Host, + Priority: mx.Pref, + Valid: true, + }) + } + + return &results +} + +func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) { + // Having valid MX records is critical for email deliverability + // From domain MX records (half points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 50 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go new file mode 100644 index 0000000..bc7a1be --- /dev/null +++ b/pkg/analyzer/dns_spf.go @@ -0,0 +1,268 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { + visited := make(map[string]bool) + return d.resolveSPFRecords(domain, visited, 0) +} + +// resolveSPFRecords recursively resolves SPF records including include: directives +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { + const maxDepth = 10 // Prevent infinite recursion + + if depth > maxDepth { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("Maximum SPF include depth exceeded"), + }, + } + } + + // Prevent circular references + if visited[domain] { + return &[]api.SPFRecord{} + } + visited[domain] = true + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, domain) + if err != nil { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + }, + } + } + + // Find SPF record (starts with "v=spf1") + var spfRecord string + spfCount := 0 + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=spf1") { + spfRecord = txt + spfCount++ + } + } + + if spfCount == 0 { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("No SPF record found"), + }, + } + } + + var results []api.SPFRecord + + if spfCount > 1 { + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: false, + Error: api.PtrTo("Multiple SPF records found (RFC violation)"), + }) + return &results + } + + // Basic validation + valid := d.validateSPF(spfRecord) + + // Extract the "all" mechanism qualifier + var allQualifier *api.SPFRecordAllQualifier + var errMsg *string + + if !valid { + errMsg = api.PtrTo("SPF record appears malformed") + } else { + // Extract qualifier from the "all" mechanism + if strings.HasSuffix(spfRecord, " -all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) + } else if strings.HasSuffix(spfRecord, " ~all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) + } else if strings.HasSuffix(spfRecord, " +all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } else if strings.HasSuffix(spfRecord, " ?all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) + } else if strings.HasSuffix(spfRecord, " all") { + // Implicit + qualifier (default) + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } + } + + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: valid, + AllQualifier: allQualifier, + Error: errMsg, + }) + + // Check for redirect= modifier first (it replaces the entire SPF policy) + redirectDomain := d.extractSPFRedirect(spfRecord) + if redirectDomain != "" { + // redirect= replaces the current domain's policy entirely + // Only follow if no other mechanisms matched (per RFC 7208) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) + if redirectRecords != nil { + results = append(results, *redirectRecords...) + } + return &results + } + + // Extract and resolve include: directives + includes := d.extractSPFIncludes(spfRecord) + for _, includeDomain := range includes { + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) + if includedRecords != nil { + results = append(results, *includedRecords...) + } + } + + return &results +} + +// extractSPFIncludes extracts all include: domains from an SPF record +func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { + var includes []string + re := regexp.MustCompile(`include:([^\s]+)`) + matches := re.FindAllStringSubmatch(record, -1) + for _, match := range matches { + if len(match) > 1 { + includes = append(includes, match[1]) + } + } + return includes +} + +// extractSPFRedirect extracts the redirect= domain from an SPF record +// The redirect= modifier replaces the current domain's SPF policy with that of the target domain +func (d *DNSAnalyzer) extractSPFRedirect(record string) string { + re := regexp.MustCompile(`redirect=([^\s]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// validateSPF performs basic SPF record validation +func (d *DNSAnalyzer) validateSPF(record string) bool { + // Must start with v=spf1 + if !strings.HasPrefix(record, "v=spf1") { + return false + } + + // Check for redirect= modifier (which replaces the need for an 'all' mechanism) + if strings.Contains(record, "redirect=") { + return true + } + + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } + } + + return hasValidEnding +} + +// hasSPFStrictFail checks if SPF record has strict -all mechanism +func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { + return strings.HasSuffix(record, " -all") +} + +func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { + // SPF is essential for email authentication + if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { + // Find the main SPF record by skipping redirects + // Loop through records to find the last redirect or the first non-redirect + mainSPFIndex := 0 + for i := 0; i < len(*results.SpfRecords); i++ { + spfRecord := (*results.SpfRecords)[i] + if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { + // This is a redirect, check if there's a next record + if i+1 < len(*results.SpfRecords) { + mainSPFIndex = i + 1 + } else { + // Redirect exists but no target record found + break + } + } else { + // Found a non-redirect record + mainSPFIndex = i + break + } + } + + mainSPF := (*results.SpfRecords)[mainSPFIndex] + if mainSPF.Valid { + // Full points for valid SPF + score += 75 + + // Deduct points based on the all mechanism qualifier + if mainSPF.AllQualifier != nil { + switch *mainSPF.AllQualifier { + case "-": + // Strict fail - no deduction, this is the recommended policy + score += 25 + case "~": + // Softfail - moderate penalty + case "+", "?": + // Pass/neutral - severe penalty + score -= 25 + } + } else { + // No 'all' mechanism qualifier extracted - severe penalty + score -= 25 + } + } else if mainSPF.Record != nil { + // Partial credit if SPF record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go new file mode 100644 index 0000000..132f063 --- /dev/null +++ b/pkg/analyzer/dns_spf_test.go @@ -0,0 +1,137 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + "time" +) + +func TestValidateSPF(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid SPF with -all", + record: "v=spf1 include:_spf.example.com -all", + expected: true, + }, + { + name: "Valid SPF with ~all", + record: "v=spf1 ip4:192.0.2.0/24 ~all", + expected: true, + }, + { + name: "Valid SPF with +all", + record: "v=spf1 +all", + expected: true, + }, + { + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expected: true, + }, + { + name: "Valid SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expected: true, + }, + { + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expected: true, + }, + { + name: "Invalid SPF - no version", + record: "include:_spf.example.com -all", + expected: false, + }, + { + name: "Invalid SPF - no all mechanism or redirect", + record: "v=spf1 include:_spf.example.com", + expected: false, + }, + { + name: "Invalid SPF - wrong version", + record: "v=spf2 include:_spf.example.com -all", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateSPF(tt.record) + if result != tt.expected { + t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractSPFRedirect(t *testing.T) { + tests := []struct { + name string + record string + expectedRedirect string + }{ + { + name: "SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectedRedirect: "_spf.example.com", + }, + { + name: "SPF with redirect and other mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", + expectedRedirect: "_spf.google.com", + }, + { + name: "SPF without redirect", + record: "v=spf1 include:_spf.example.com -all", + expectedRedirect: "", + }, + { + name: "SPF with only all mechanism", + record: "v=spf1 -all", + expectedRedirect: "", + }, + { + name: "Empty record", + record: "", + expectedRedirect: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractSPFRedirect(tt.record) + if result != tt.expectedRedirect { + t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) + } + }) + } +} diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 10b7b98..bba4503 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -56,581 +56,3 @@ func TestNewDNSAnalyzer(t *testing.T) { }) } } -func TestValidateSPF(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid SPF with -all", - record: "v=spf1 include:_spf.example.com -all", - expected: true, - }, - { - name: "Valid SPF with ~all", - record: "v=spf1 ip4:192.0.2.0/24 ~all", - expected: true, - }, - { - name: "Valid SPF with +all", - record: "v=spf1 +all", - expected: true, - }, - { - name: "Valid SPF with ?all", - record: "v=spf1 mx ?all", - expected: true, - }, - { - name: "Valid SPF with redirect", - record: "v=spf1 redirect=_spf.example.com", - expected: true, - }, - { - name: "Valid SPF with redirect and mechanisms", - record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", - expected: true, - }, - { - name: "Invalid SPF - no version", - record: "include:_spf.example.com -all", - expected: false, - }, - { - name: "Invalid SPF - no all mechanism or redirect", - record: "v=spf1 include:_spf.example.com", - expected: false, - }, - { - name: "Invalid SPF - wrong version", - record: "v=spf2 include:_spf.example.com -all", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateSPF(tt.record) - if result != tt.expected { - t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractSPFRedirect(t *testing.T) { - tests := []struct { - name string - record string - expectedRedirect string - }{ - { - name: "SPF with redirect", - record: "v=spf1 redirect=_spf.example.com", - expectedRedirect: "_spf.example.com", - }, - { - name: "SPF with redirect and other mechanisms", - record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", - expectedRedirect: "_spf.google.com", - }, - { - name: "SPF without redirect", - record: "v=spf1 include:_spf.example.com -all", - expectedRedirect: "", - }, - { - name: "SPF with only all mechanism", - record: "v=spf1 -all", - expectedRedirect: "", - }, - { - name: "Empty record", - record: "", - expectedRedirect: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractSPFRedirect(tt.record) - if result != tt.expectedRedirect { - t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) - } - }) - } -} - -func TestValidateDKIM(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DKIM with version", - record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Valid DKIM without version", - record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Invalid DKIM - no public key", - record: "v=DKIM1; k=rsa", - expected: false, - }, - { - name: "Invalid DKIM - wrong version", - record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: false, - }, - { - name: "Invalid DKIM - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDKIM(tt.record) - if result != tt.expected { - t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractDMARCPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy string - }{ - { - name: "Policy none", - record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", - expectedPolicy: "none", - }, - { - name: "Policy quarantine", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPolicy: "quarantine", - }, - { - name: "Policy reject", - record: "v=DMARC1; p=reject; sp=reject", - expectedPolicy: "reject", - }, - { - name: "No policy", - record: "v=DMARC1", - expectedPolicy: "unknown", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) - } - }) - } -} - -func TestValidateDMARC(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DMARC", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - expected: true, - }, - { - name: "Valid DMARC minimal", - record: "v=DMARC1; p=none", - expected: true, - }, - { - name: "Invalid DMARC - no version", - record: "p=quarantine", - expected: false, - }, - { - name: "Invalid DMARC - no policy", - record: "v=DMARC1", - expected: false, - }, - { - name: "Invalid DMARC - wrong version", - record: "v=DMARC2; p=reject", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDMARC(tt.record) - if result != tt.expected { - t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func 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 TestExtractDMARCSPFAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "SPF alignment - strict", - record: "v=DMARC1; p=quarantine; aspf=s", - expectedAlignment: "strict", - }, - { - name: "SPF alignment - relaxed (explicit)", - record: "v=DMARC1; p=quarantine; aspf=r", - expectedAlignment: "relaxed", - }, - { - name: "SPF alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=quarantine", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check SPF strict", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check SPF relaxed", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with SPF strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSPFAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCDKIMAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "DKIM alignment - strict", - record: "v=DMARC1; p=reject; adkim=s", - expectedAlignment: "strict", - }, - { - name: "DKIM alignment - relaxed (explicit)", - record: "v=DMARC1; p=reject; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "DKIM alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=none", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check DKIM strict", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check DKIM relaxed", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with DKIM strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCDKIMAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCSubdomainPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy *string - }{ - { - name: "Subdomain policy - none", - record: "v=DMARC1; p=quarantine; sp=none", - expectedPolicy: stringPtr("none"), - }, - { - name: "Subdomain policy - quarantine", - record: "v=DMARC1; p=reject; sp=quarantine", - expectedPolicy: stringPtr("quarantine"), - }, - { - name: "Subdomain policy - reject", - record: "v=DMARC1; p=quarantine; sp=reject", - expectedPolicy: stringPtr("reject"), - }, - { - name: "No subdomain policy specified (defaults to main policy)", - record: "v=DMARC1; p=quarantine", - expectedPolicy: nil, - }, - { - name: "Complex record with subdomain policy", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: stringPtr("quarantine"), - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSubdomainPolicy(tt.record) - if tt.expectedPolicy == nil { - if result != nil { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) - } - } else { - if result == nil { - t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) - } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) - } - } - }) - } -} - -func TestExtractDMARCPercentage(t *testing.T) { - tests := []struct { - name string - record string - expectedPercentage *int - }{ - { - name: "Percentage - 100", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPercentage: intPtr(100), - }, - { - name: "Percentage - 50", - record: "v=DMARC1; p=quarantine; pct=50", - expectedPercentage: intPtr(50), - }, - { - name: "Percentage - 25", - record: "v=DMARC1; p=reject; pct=25", - expectedPercentage: intPtr(25), - }, - { - name: "Percentage - 0", - record: "v=DMARC1; p=none; pct=0", - expectedPercentage: intPtr(0), - }, - { - name: "No percentage specified (defaults to 100)", - record: "v=DMARC1; p=quarantine", - expectedPercentage: nil, - }, - { - name: "Complex record with percentage", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", - expectedPercentage: intPtr(75), - }, - { - name: "Invalid percentage > 100 (ignored)", - record: "v=DMARC1; p=quarantine; pct=150", - expectedPercentage: nil, - }, - { - name: "Invalid percentage < 0 (ignored)", - record: "v=DMARC1; p=quarantine; pct=-10", - expectedPercentage: nil, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPercentage(tt.record) - if tt.expectedPercentage == nil { - if result != nil { - t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) - } - } else { - if result == nil { - t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) - } - if *result != *tt.expectedPercentage { - t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) - } - } - }) - } -} - -// Helper functions for test pointers -func stringPtr(s string) *string { - return &s -} - -func intPtr(i int) *int { - return &i -} From 52f43c6bc5003c9bc7a77c525b61760ef7177652 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 15:21:54 +0700 Subject: [PATCH 089/256] Add x-align-form authentication test --- api/openapi.yaml | 3 + pkg/analyzer/authentication.go | 14 +- pkg/analyzer/authentication_test.go | 6 +- pkg/analyzer/authentication_x_aligned_from.go | 65 ++++++++ .../authentication_x_aligned_from_test.go | 144 ++++++++++++++++++ .../lib/components/AuthenticationCard.svelte | 24 +++ .../lib/components/HeaderAnalysisCard.svelte | 11 +- web/src/routes/test/[test]/+page.svelte | 1 + 8 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 pkg/analyzer/authentication_x_aligned_from.go create mode 100644 pkg/analyzer/authentication_x_aligned_from_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index a178dc9..f5eb96a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -683,6 +683,9 @@ components: x_google_dkim: $ref: '#/components/schemas/AuthResult' description: Google-specific DKIM authentication result (x-google-dkim) + x_aligned_from: + $ref: '#/components/schemas/AuthResult' + description: X-Aligned-From authentication result (checks address alignment) AuthResult: type: object diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bc6ae38..02f8b28 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -138,6 +138,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.XGoogleDkim = a.parseXGoogleDKIMResult(part) } } + + // Parse x-aligned-from + if strings.HasPrefix(part, "x-aligned-from=") { + if results.XAlignedFrom == nil { + results.XAlignedFrom = a.parseXAlignedFromResult(part) + } + } } } @@ -156,12 +163,15 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe // SPF (25 points) score += 25 * a.calculateSPFScore(results) / 100 - // DKIM (25 points) - score += 25 * a.calculateDKIMScore(results) / 100 + // DKIM (23 points) + score += 23 * a.calculateDKIMScore(results) / 100 // X-Google-DKIM (optional) - penalty if failed score += 12 * a.calculateXGoogleDKIMScore(results) / 100 + // X-Aligned-From + score += 2 * a.calculateXAlignedFromScore(results) / 100 + // DMARC (25 points) score += 25 * a.calculateDMARCScore(results) / 100 diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 63f9e2d..27901b5 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -46,7 +46,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 75, // SPF=25 + DKIM=25 + DMARC=25 + expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", @@ -58,7 +58,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 50, // SPF=25 + DKIM=25 + expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", @@ -70,7 +70,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 25, // SPF=0 + DKIM=25 + expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go new file mode 100644 index 0000000..36da2b0 --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -0,0 +1,65 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results +// Example: x-aligned-from=pass (Address match) +func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-aligned-from=([\w]+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract details (everything after the result) + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { + if results.XAlignedFrom != nil { + switch results.XAlignedFrom.Result { + case api.AuthResultResultPass: + // pass: positive contribution + return 100 + case api.AuthResultResultFail: + // fail: negative contribution + return 0 + default: + // neutral, none, etc.: no impact + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go new file mode 100644 index 0000000..220ac39 --- /dev/null +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -0,0 +1,144 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseXAlignedFromResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDetail string + }{ + { + name: "x-aligned-from pass with details", + part: "x-aligned-from=pass (Address match)", + expectedResult: api.AuthResultResultPass, + expectedDetail: "pass (Address match)", + }, + { + name: "x-aligned-from fail with reason", + part: "x-aligned-from=fail (Address mismatch)", + expectedResult: api.AuthResultResultFail, + expectedDetail: "fail (Address mismatch)", + }, + { + name: "x-aligned-from pass minimal", + part: "x-aligned-from=pass", + expectedResult: api.AuthResultResultPass, + expectedDetail: "pass", + }, + { + name: "x-aligned-from neutral", + part: "x-aligned-from=neutral (No alignment check performed)", + expectedResult: api.AuthResultResultNeutral, + expectedDetail: "neutral (No alignment check performed)", + }, + { + name: "x-aligned-from none", + part: "x-aligned-from=none", + expectedResult: api.AuthResultResultNone, + expectedDetail: "none", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXAlignedFromResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if result.Details == nil { + t.Errorf("Details = nil, want %v", tt.expectedDetail) + } else if *result.Details != tt.expectedDetail { + t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail) + } + }) + } +} + +func TestCalculateXAlignedFromScore(t *testing.T) { + tests := []struct { + name string + result *api.AuthResult + expectedScore int + }{ + { + name: "pass result gives positive score", + result: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + expectedScore: 100, + }, + { + name: "fail result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultFail, + }, + expectedScore: 0, + }, + { + name: "neutral result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultNeutral, + }, + expectedScore: 0, + }, + { + name: "none result gives zero score", + result: &api.AuthResult{ + Result: api.AuthResultResultNone, + }, + expectedScore: 0, + }, + { + name: "nil result gives zero score", + result: nil, + expectedScore: 0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{ + XAlignedFrom: tt.result, + } + + score := analyzer.calculateXAlignedFromScore(results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index d30ff39..cf1b80f 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -213,6 +213,30 @@
    {/if} + + {#if false && authentication.x_aligned_from} +
    +
    + +
    + X-Aligned-From + + {authentication.x_aligned_from.result} + + {#if authentication.x_aligned_from.domain} +
    + Domain: + {authentication.x_aligned_from.domain} +
    + {/if} + {#if authentication.x_aligned_from.details} +
    {authentication.x_aligned_from.details}
    + {/if} +
    +
    +
    + {/if} +
    diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index ac5ad7a..54f6743 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -1,5 +1,5 @@
    @@ -60,7 +61,11 @@
    - + {#if xAlignedFrom} + + {:else} + + {/if} Domain Alignment
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 59697e2..98fb3a1 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -198,6 +198,7 @@ headerAnalysis={report.header_analysis} headerGrade={report.summary?.header_grade} headerScore={report.summary?.header_score} + xAlignedFrom={report.authentication.x_aligned_from} />
    From 38b2be58fe1899c1295c3220fdc1c50ac5302874 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 15:31:10 +0700 Subject: [PATCH 090/256] Ensure latest created report is fetch --- internal/storage/storage.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d8a8cb4..35aa0df 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -108,7 +108,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) { // GetReport retrieves a report by test ID, returning the raw JSON and email bytes func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { var dbReport Report - if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil { + if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrNotFound } From ff1a95822078b848f093583c3ec6a0dc09dd0b46 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 15:37:39 +0700 Subject: [PATCH 091/256] Add a menu to results page --- web/src/lib/components/ScoreCard.svelte | 43 +++++---- web/src/routes/test/[test]/+page.svelte | 116 +++++++++++++++++++++--- 2 files changed, 129 insertions(+), 30 deletions(-) diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 11e5396..523940f 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -5,10 +5,11 @@ interface Props { grade: string; score: number; + reanalyzing?: boolean; summary?: ScoreSummary; } - let { grade, score, summary }: Props = $props(); + let { grade, score, reanalyzing, summary }: Props = $props(); function getScoreLabel(score: number): string { if (score >= 90) return "Excellent"; @@ -19,25 +20,22 @@ } - -
    - + {#if reanalyzing} +
    + {:else} + + {/if}
    -

    {getScoreLabel(score)}

    +

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

    Overall Deliverability Score

    {#if summary} @@ -94,3 +92,16 @@ {/if}
    + + diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 98fb3a1..c801bac 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -24,6 +24,7 @@ let pollInterval: ReturnType | null = null; let nextfetch = $state(23); let nbfetch = $state(0); + let menuOpen = $state(false); async function fetchTest() { if (nbfetch > 0) { @@ -85,6 +86,7 @@ async function handleReanalyze() { if (!testId || reanalyzing) return; + menuOpen = false; reanalyzing = true; error = null; @@ -99,6 +101,18 @@ reanalyzing = false; } } + + function handleExportJSON() { + const dataStr = JSON.stringify(report, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `report-${testId}.json`; + link.click(); + URL.revokeObjectURL(url); + menuOpen = false; + } @@ -135,7 +149,47 @@
    @@ -233,19 +287,6 @@
    - Test Another Email @@ -288,4 +329,51 @@ font-size: 1rem; font-weight: 700; } + + .menu-container { + position: relative; + } + + .menu-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.25rem; + background: white; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + min-width: 250px; + z-index: 1000; + padding: 0.5rem 0; + } + + .menu-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + background: none; + border: none; + text-align: left; + color: #212529; + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease-in-out; + font-size: 1rem; + } + + .menu-item:hover:not(:disabled) { + background-color: #f8f9fa; + } + + .menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .menu-divider { + margin: 0.5rem 0; + border: 0; + border-top: 1px solid #dee2e6; + } From eb28499dfd4ea6c5262d33cfb7ae342d5905db98 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 15:54:00 +0700 Subject: [PATCH 092/256] Monitor testId to update the page on test change --- web/src/routes/test/[test]/+page.svelte | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c801bac..9788594 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,5 +1,5 @@ @@ -33,7 +46,7 @@ {#if reanalyzing} Analyzing in progress… {:else} - {getScoreLabel(score)} + {getScoreLabel(grade)} {/if}

    Overall Deliverability Score

    From 932bc981b5b2f3ca3e68a07084d5c39213878292 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 16:12:19 +0700 Subject: [PATCH 095/256] Filter Authentication-Results to keep only local ones --- pkg/analyzer/authentication_dkim_test.go | 2 ++ pkg/analyzer/parser.go | 28 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 0d00031..323e421 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -270,6 +270,8 @@ func TestParseLegacyDKIM(t *testing.T) { } func TestParseLegacyDKIM_Integration(t *testing.T) { + hostname = "" + // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { analyzer := NewAuthenticationAnalyzer() diff --git a/pkg/analyzer/parser.go b/pkg/analyzer/parser.go index 13c012c..ca3cb46 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,9 +28,16 @@ import ( "mime/multipart" "net/mail" "net/textproto" + "os" "strings" ) +var hostname = "" + +func init() { + hostname, _ = os.Hostname() +} + // EmailMessage represents a parsed email message type EmailMessage struct { Header mail.Header @@ -211,8 +218,27 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers +// If hostname is provided, only returns headers that begin with that hostname func (e *EmailMessage) GetAuthenticationResults() []string { - return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] + allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] + + // If no hostname specified, return all results + if hostname == "" { + return allResults + } + + // Filter results that begin with the specified hostname + var filtered []string + prefix := hostname + ";" + for _, result := range allResults { + // Trim whitespace and check if it starts with hostname; + trimmed := strings.TrimSpace(result) + if strings.HasPrefix(trimmed, prefix) { + filtered = append(filtered, result) + } + } + + return filtered } // GetSpamAssassinHeaders extracts SpamAssassin-related headers From 08c6e0eef2f2f3b5383ea2148d039db14c42f624 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 16:25:01 +0700 Subject: [PATCH 096/256] Rate limit API requests --- go.mod | 6 +++++- go.sum | 10 ++++++++++ internal/app/server.go | 22 +++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 7604b07..db5883d 100644 --- a/go.mod +++ b/go.mod @@ -15,10 +15,13 @@ require ( ) require ( + github.com/JGLTechnologies/gin-rate-limit v1.5.6 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect @@ -28,7 +31,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // 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 @@ -53,6 +56,7 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.1 // indirect + github.com/redis/go-redis/v9 v9.7.3 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index bc46bc0..b378447 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= +github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= @@ -6,6 +8,8 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -14,6 +18,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -49,6 +55,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -146,6 +154,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= diff --git a/internal/app/server.go b/internal/app/server.go index 0c70eef..fbb7a31 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -25,7 +25,9 @@ import ( "context" "log" "os" + "time" + ratelimit "github.com/JGLTechnologies/gin-rate-limit" "github.com/gin-gonic/gin" "git.happydns.org/happyDeliver/internal/api" @@ -76,8 +78,26 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - // Register API routes + // Set up rate limiting (1 request per second per IP) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 4 * time.Second, + Limit: 2, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + // Register API routes with rate limiting apiGroup := router.Group("/api") + apiGroup.Use(rateLimiter) api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) From 53a48cba076fb8b1e7822eb38a49acf869a44853 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 17:20:35 +0700 Subject: [PATCH 097/256] Fix typescript/svelte checks --- .../lib/components/AuthenticationCard.svelte | 34 ++-- .../lib/components/BimiRecordDisplay.svelte | 4 +- web/src/lib/components/BlacklistCard.svelte | 4 +- .../lib/components/DkimRecordsDisplay.svelte | 4 +- .../lib/components/DmarcRecordDisplay.svelte | 4 +- web/src/lib/components/DnsRecordsCard.svelte | 4 +- web/src/lib/components/GradeDisplay.svelte | 2 +- .../lib/components/HeaderAnalysisCard.svelte | 6 +- .../lib/components/MxRecordsDisplay.svelte | 6 +- web/src/lib/components/PendingState.svelte | 2 + .../lib/components/SpamAssassinCard.svelte | 4 +- .../lib/components/SpfRecordsDisplay.svelte | 20 +- web/src/lib/components/SummaryCard.svelte | 182 ++++++++++-------- web/src/lib/hey-api.ts | 4 +- web/src/routes/+layout.svelte | 31 ++- web/src/routes/test/+page.ts | 5 +- web/src/routes/test/[test]/+page.svelte | 38 ++-- 17 files changed, 199 insertions(+), 155 deletions(-) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index cf1b80f..b76b48a 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -1,13 +1,13 @@ @@ -58,13 +58,13 @@ {#if spf.all_qualifier}
    All Mechanism Policy: - {#if spf.all_qualifier === '-'} + {#if spf.all_qualifier === "-"} Strict (-all) - {:else if spf.all_qualifier === '~'} + {:else if spf.all_qualifier === "~"} Softfail (~all) - {:else if spf.all_qualifier === '+'} + {:else if spf.all_qualifier === "+"} Pass (+all) - {:else if spf.all_qualifier === '?'} + {:else if spf.all_qualifier === "?"} Neutral (?all) {/if} {#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))} diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 971c1ac..9eb6272 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -5,8 +5,10 @@ interface TextSegment { text: string; highlight?: { - color: "good" | "warning" | "danger"; + color?: "good" | "warning" | "danger"; bold?: boolean; + emphasis?: boolean; + monospace?: boolean; }; link?: string; } @@ -22,19 +24,19 @@ // Email sender information const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; - const hasDkim = report.authentication?.dkim && report.authentication.dkim.length > 0; - const dkimPassed = hasDkim && report.authentication.dkim.some(d => d.result === "pass"); + const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0; + const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass"); segments.push({ text: "Received a " }); segments.push({ text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", highlight: { color: dkimPassed ? "good" : "danger", bold: true }, - link: "#authentication-dkim" + link: "#authentication-dkim", }); segments.push({ text: " email from " }); segments.push({ text: mailFrom, - highlight: { emphasis: true } + highlight: { emphasis: true }, }); // Server information and hops @@ -47,12 +49,12 @@ segments.push({ text: serverName, highlight: { monospace: true }, - link: "#header-details" + link: "#header-details", }); segments.push({ text: " after " }); segments.push({ - text: `${hopCount-1} hop${hopCount-1 !== 1 ? "s" : ""}`, - link: "#email-path" + text: `${hopCount - 1} hop${hopCount - 1 !== 1 ? "s" : ""}`, + link: "#email-path", }); } @@ -65,22 +67,25 @@ segments.push({ text: "authenticated", highlight: { color: "good", bold: true }, - link: "#authentication-details" + link: "#authentication-details", }); segments.push({ text: " to send email on behalf of " }); - segments.push({ text: report.header_analysis?.domain_alignment?.from_domain, highlight: {monospace: true} }); + segments.push({ + text: report.header_analysis?.domain_alignment?.from_domain || "unknown domain", + highlight: { monospace: true }, + }); } else if (spfResult && spfResult !== "none") { segments.push({ text: "not authenticated", highlight: { color: "danger", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", }); segments.push({ text: " (failed authentication checks)" }); } else { segments.push({ text: "not authenticated", highlight: { color: "warning", bold: true }, - link: "#authentication-details" + link: "#authentication-details", }); segments.push({ text: " (lacks proper authentication)" }); } @@ -92,21 +97,23 @@ segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", + }); + segments.push({ + text: ", the sending server is not authorized to send mail for this domain", }); - segments.push({ text: ", the sending server is not authorized to send mail for this domain" }); } else if (spfResult === "softfail") { segments.push({ text: "soft-failed", highlight: { color: "warning", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", }); segments.push({ text: ", the sending server may not be authorized" }); } else if (spfResult === "temperror" || spfResult === "permerror") { segments.push({ text: "encountered an error", highlight: { color: "warning", bold: true }, - link: "#authentication-spf" + link: "#authentication-spf", }); segments.push({ text: ", check your SPF record configuration" }); } else if (spfResult === "none") { @@ -114,9 +121,11 @@ segments.push({ text: "no SPF record", highlight: { color: "danger", bold: true }, - link: "#dns-spf" + link: "#dns-spf", + }); + segments.push({ + text: ", you should add one to specify which servers can send email on your behalf", }); - segments.push({ text: ", you should add one to specify which servers can send email on your behalf" }); } } @@ -129,13 +138,13 @@ segments.push({ text: "good", highlight: { color: "good", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); } else if (iprevResult.result === "fail") { segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); segments.push({ text: " to pass the test" }); } else { @@ -143,7 +152,7 @@ segments.push({ text: iprevResult.result, highlight: { color: "warning", bold: true }, - link: "#dns-ptr" + link: "#dns-ptr", }); } } @@ -152,20 +161,20 @@ const blacklists = report.blacklists; if (blacklists && Object.keys(blacklists).length > 0) { const allChecks = Object.values(blacklists).flat(); - const listedCount = allChecks.filter(check => check.listed).length; + const listedCount = allChecks.filter((check) => check.listed).length; segments.push({ text: ". Your server is " }); if (listedCount > 0) { segments.push({ text: `blacklisted on ${listedCount} list${listedCount !== 1 ? "s" : ""}`, highlight: { color: "danger", bold: true }, - link: "#rbl-details" + link: "#rbl-details", }); } else { segments.push({ text: "not blacklisted", highlight: { color: "good", bold: true }, - link: "#rbl-details" + link: "#rbl-details", }); } } @@ -178,7 +187,7 @@ segments.push({ text: "good", highlight: { color: "good", bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", }); if (!domainAlignment.aligned) { segments.push({ text: " using organizational domain" }); @@ -187,17 +196,22 @@ segments.push({ text: "misaligned", highlight: { color: "danger", bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", }); segments.push({ text: ": " }); segments.push({ text: "Return-Path", highlight: { monospace: true } }); segments.push({ text: " is set to an address of " }); - segments.push({ text: report.header_analysis?.domain_alignment?.return_path_domain, highlight: { monospace: true } }); + segments.push({ + text: + report.header_analysis?.domain_alignment?.return_path_domain || + "unknown domain", + highlight: { monospace: true }, + }); segments.push({ text: ", you should " }); segments.push({ text: "update it", highlight: { bold: true }, - link: "#domain-alignment" + link: "#domain-alignment", }); } } @@ -210,25 +224,28 @@ segments.push({ text: "don't have", highlight: { color: "danger", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: " a DMARC record, " }); - segments.push({ text: "consider adding at least a record with the '", highlight: { bold : true } }); + segments.push({ + text: "consider adding at least a record with the '", + highlight: { bold: true }, + }); segments.push({ text: "none", highlight: { monospace: true, bold: true } }); - segments.push({ text: "' policy", highlight: { bold : true } }); + segments.push({ text: "' policy", highlight: { bold: true } }); } else if (!dmarcRecord.valid) { segments.push({ text: ". Your DMARC record has " }); segments.push({ text: "issues", highlight: { color: "danger", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); } else if (dmarcRecord.policy === "none") { segments.push({ text: ". Your DMARC policy is " }); segments.push({ text: "set to 'none'", highlight: { color: "warning", bold: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: ", which provides monitoring but no protection" }); } else if (dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject") { @@ -236,7 +253,7 @@ segments.push({ text: dmarcRecord.policy, highlight: { color: "good", bold: true, monospace: true }, - link: "#dns-dmarc" + link: "#dns-dmarc", }); segments.push({ text: "'" }); if (dmarcRecord.policy === "reject") { @@ -247,17 +264,17 @@ segments.push({ text: "'" }); } } - } else if (dmarcResult && dmarcResult.result === "fail") { + } else if (dmarcResult === "fail") { segments.push({ text: ". DMARC check " }); segments.push({ text: "failed", highlight: { color: "danger", bold: true }, - link: "#authentication-dmarc" + link: "#authentication-dmarc", }); } // BIMI - if (dmarcRecord.valid && dmarcRecord.policy != "none") { + if (dmarcRecord && dmarcRecord.valid && dmarcRecord.policy != "none") { const bimiResult = report.authentication?.bimi; const bimiRecord = report.dns_results?.bimi_record; if (bimiRecord?.valid) { @@ -268,7 +285,7 @@ link: "#dns-bimi" }); segments.push({ text: " for brand indicator display" }); - } else if (bimiResult && bimiResult.details.indexOf("(No BIMI records found)") >= 0) { + } else if (bimiResult && bimiResult.details && bimiResult.details.indexOf("(No BIMI records found)") >= 0) { segments.push({ text: ". Your domain has no " }); segments.push({ text: "BIMI record", @@ -293,19 +310,21 @@ segments.push({ text: ". " }); segments.push({ text: "ARC chain validation", - link: "#authentication-arc" + link: "#authentication-arc", }); segments.push({ text: " " }); if (arcResult.chain_valid) { segments.push({ text: "passed", - highlight: { color: "good", bold: true } + highlight: { color: "good", bold: true }, + }); + segments.push({ + text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding`, }); - segments.push({ text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding` }); } else { segments.push({ text: "failed", - highlight: { color: "danger", bold: true } + highlight: { color: "danger", bold: true }, }); segments.push({ text: ", which may indicate issues with email forwarding" }); } @@ -316,20 +335,25 @@ const listUnsubscribe = headers?.["list-unsubscribe"]; const listUnsubscribePost = headers?.["list-unsubscribe-post"]; - const hasNewsletterHeaders = (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) || - (listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present); + const hasNewsletterHeaders = + (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) || + (listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present); - if (!hasNewsletterHeaders && (listUnsubscribe?.importance === "newsletter" || listUnsubscribePost?.importance === "newsletter")) { + if ( + !hasNewsletterHeaders && + (listUnsubscribe?.importance === "newsletter" || + listUnsubscribePost?.importance === "newsletter") + ) { segments.push({ text: ". This email is " }); segments.push({ text: "missing unsubscribe headers", highlight: { color: "warning", bold: true }, - link: "#header-details" + link: "#header-details", }); segments.push({ text: " and is " }); segments.push({ text: "not suitable for marketing campaigns", - highlight: { bold: true } + highlight: { bold: true }, }); } @@ -344,7 +368,7 @@ segments.push({ text: "flagged as spam", highlight: { color: "danger", bold: true }, - link: "#spam-details" + link: "#spam-details", }); segments.push({ text: " and needs review" }); } else if (contentScore < 50) { @@ -352,49 +376,55 @@ segments.push({ text: "needs improvement", highlight: { color: "warning", bold: true }, - link: "#content-details" + link: "#content-details", }); } else if (contentScore >= 100 && spamScore >= 100) { segments.push({ text: "Content " }); segments.push({ text: "looks great", highlight: { color: "good", bold: true }, - link: "#content-details" + link: "#content-details", }); } else if (spamScore < 50) { segments.push({ text: "Your " }); segments.push({ text: "spam score", highlight: { color: "danger", bold: true }, - link: "#spam-details" + link: "#spam-details", }); segments.push({ text: " is low" }); - if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { - segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + if (report.spamassassin?.tests?.includes("EMPTY_MESSAGE")) { + segments.push({ + text: " (you sent an empty message, which can cause this issue, retry with some real content)", + highlight: { bold: true }, + }); } } else if (spamScore < 90) { segments.push({ text: "Pay attention to your " }); segments.push({ text: "spam score", highlight: { color: "warning", bold: true }, - link: "#spam-details" + link: "#spam-details", }); - if (report.spamassassin.tests.includes("EMPTY_MESSAGE")) { - segments.push({ text: " (you sent an empty message, which can cause this issue, retry with some real content)", highlight: { bold: true } }); + if (report.spamassassin?.tests?.includes("EMPTY_MESSAGE")) { + segments.push({ + text: " (you sent an empty message, which can cause this issue, retry with some real content)", + highlight: { bold: true }, + }); } } else if (contentScore >= 80) { segments.push({ text: "Content " }); segments.push({ text: "looks good", highlight: { color: "good", bold: true }, - link: "#content-details" + link: "#content-details", }); } else { segments.push({ text: "Content " }); segments.push({ text: "should be reviewed", highlight: { color: "warning", bold: true }, - link: "#content-details" + link: "#content-details", }); } @@ -403,7 +433,7 @@ return segments; } - function getColorClass(color: "good" | "warning" | "danger"): string { + function getColorClass(color?: "good" | "warning" | "danger"): string { switch (color) { case "good": return "text-success"; @@ -411,28 +441,14 @@ return "text-warning"; case "danger": return "text-danger"; + default: + return ""; } } const summarySegments = $derived(buildSummary()); - -
    @@ -460,3 +476,19 @@

    + + diff --git a/web/src/lib/hey-api.ts b/web/src/lib/hey-api.ts index e75e70a..6983e5d 100644 --- a/web/src/lib/hey-api.ts +++ b/web/src/lib/hey-api.ts @@ -7,8 +7,8 @@ export class NotAuthorizedError extends Error { } } -async function customFetch(url: string, init: RequestInit): Promise { - const response = await fetch(url, init); +async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); if (response.status === 400) { const json = await response.json(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f0031bb..b295819 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -38,7 +38,12 @@
    diff --git a/web/src/routes/test/+page.ts b/web/src/routes/test/+page.ts index d2f88f2..8f8fd5b 100644 --- a/web/src/routes/test/+page.ts +++ b/web/src/routes/test/+page.ts @@ -10,10 +10,11 @@ export const load: Load = async ({}) => { try { response = await apiCreateTest(); } catch (err) { - error(err.response.status, err.message); + const errorObj = err as { response?: { status?: number }; message?: string }; + error(errorObj.response?.status || 500, errorObj.message || "Unknown error"); } - if (response.response.ok) { + if (response.response.ok && response.data) { redirect(302, `/test/${response.data.id}`); } else { error(response.response.status, response.error); diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 8ac67eb..7ef2b63 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -28,6 +28,8 @@ let fetching = $state(false); async function fetchTest() { + if (!testId) return; + if (nbfetch > 0) { nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5)); } @@ -89,8 +91,8 @@ } } - let lastTestId = null; - function testChange(newTestId) { + let lastTestId: string | null = null; + function testChange(newTestId: string) { if (lastTestId != newTestId) { lastTestId = newTestId; test = null; @@ -100,7 +102,10 @@ } $effect(() => { - testChange(page.params.test); + const newTestId = page.params.test; + if (newTestId) { + testChange(newTestId); + } }) onDestroy(() => { @@ -128,9 +133,9 @@ function handleExportJSON() { const dataStr = JSON.stringify(report, null, 2); - const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const dataBlob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.download = `report-${testId}.json`; link.click(); @@ -140,7 +145,7 @@ - {report ? `Test of ${report.dns_results.from_domain} ${report.test_id.slice(0, 7)}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver + {report ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ''} ${report.test_id?.slice(0, 7) || ''}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver
    @@ -283,11 +288,11 @@ @@ -348,23 +353,6 @@ } } - .category-section { - margin-bottom: 2rem; - } - - .category-title { - font-size: 1.25rem; - font-weight: 600; - color: #495057; - padding-bottom: 0.5rem; - border-bottom: 2px solid #e9ecef; - } - - .category-score { - font-size: 1rem; - font-weight: 700; - } - .menu-container { position: relative; } From edb172c4bcfc5740f853fabf632ead5d966081ea Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 17:35:58 +0700 Subject: [PATCH 098/256] Update features on home page --- web/routes.go | 4 +++ web/src/lib/config.ts | 48 ++++++++++++++++++++++++++++ web/src/routes/+page.svelte | 62 +++++++++++++++++++++++++++++-------- 3 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/config.ts diff --git a/web/routes.go b/web/routes.go index f67b453..184da64 100644 --- a/web/routes.go +++ b/web/routes.go @@ -54,6 +54,10 @@ func init() { func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig := map[string]interface{}{} + if cfg.ReportRetention > 0 { + appConfig["report_retention"] = cfg.ReportRetention + } + if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { log.Println("Unable to generate JSON config to inject in web application") } else { diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..65eb1bb --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,48 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { writable } from "svelte/store"; + +interface AppConfig { + report_retention?: number; +} + +const defaultConfig: AppConfig = { + report_retention: 0, +}; + +function getConfigFromScriptTag(): AppConfig | null { + if (typeof document !== "undefined") { + const configScript = document.getElementById("app-config"); + if (configScript) { + try { + return JSON.parse(configScript.textContent || ""); + } catch (e) { + console.error("Failed to parse app config:", e); + } + } + } + return null; +} + +const initialConfig = getConfigFromScriptTag() || defaultConfig; + +export const appConfig = writable(initialConfig); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index ecfbbdd..8783582 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,6 +1,7 @@ + +{#if $appConfig.surveyUrl} +
    + {#if step === 0} + {#if question}{@render question()}{:else} +

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

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

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

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

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

    + {/if} + +{/if} diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index 8b83ae5..e600c11 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -13,3 +13,4 @@ export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; +export { default as TinySurvey } from "./TinySurvey.svelte"; diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 65eb1bb..c4c0bd4 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -23,10 +23,12 @@ import { writable } from "svelte/store"; interface AppConfig { report_retention?: number; + surveyUrl?: string; } const defaultConfig: AppConfig = { report_retention: 0, + surveyUrl: "", }; function getConfigFromScriptTag(): AppConfig | null { diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 7ef2b63..7f50923 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -12,7 +12,8 @@ DnsRecordsCard, BlacklistCard, ContentAnalysisCard, - HeaderAnalysisCard + HeaderAnalysisCard, + TinySurvey, } from "$lib/components"; let testId = $derived(page.params.test); @@ -236,7 +237,11 @@
    - + +
    + +
    +
    From 4c71dd1d5353a74d52b6bc82de083029c2c3bf17 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 18:27:51 +0700 Subject: [PATCH 101/256] Handle errors on test page --- web/src/lib/components/ErrorDisplay.svelte | 158 +++++++++++++++++++++ web/src/lib/components/index.ts | 1 + web/src/routes/+error.svelte | 126 +--------------- web/src/routes/test/[test]/+page.svelte | 57 ++++++-- 4 files changed, 209 insertions(+), 133 deletions(-) create mode 100644 web/src/lib/components/ErrorDisplay.svelte diff --git a/web/src/lib/components/ErrorDisplay.svelte b/web/src/lib/components/ErrorDisplay.svelte new file mode 100644 index 0000000..96cfae2 --- /dev/null +++ b/web/src/lib/components/ErrorDisplay.svelte @@ -0,0 +1,158 @@ + + +
    +
    + +
    + +
    + + +

    {status}

    + + +

    {getErrorTitle(status)}

    + + +

    {getErrorDescription(status)}

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

    Looking for something specific?

    + +
    + {/if} +
    +
    + + diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index e600c11..dadab9e 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -14,3 +14,4 @@ export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; +export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 5d0514c..a429ea5 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,5 +1,6 @@ @@ -55,96 +28,5 @@
    -
    -
    - -
    - -
    - - -

    {status}

    - - -

    {getErrorTitle(status)}

    - - -

    {getErrorDescription(status)}

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

    Looking for something specific?

    - -
    - {/if} -
    -
    +
    - - diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 7f50923..c8b5cc0 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -14,6 +14,7 @@ ContentAnalysisCard, HeaderAnalysisCard, TinySurvey, + ErrorDisplay, } from "$lib/components"; let testId = $derived(page.params.test); @@ -21,6 +22,7 @@ let report = $state(null); let loading = $state(true); let error = $state(null); + let errorStatus = $state(500); let reanalyzing = $state(false); let pollInterval: ReturnType | null = null; let nextfetch = $state(23); @@ -28,6 +30,36 @@ let menuOpen = $state(false); let fetching = $state(false); + // Helper function to handle API errors + function handleApiError(apiError: unknown, defaultMessage: string) { + if (apiError && typeof apiError === "object") { + if ("message" in apiError) { + error = String(apiError.message); + } else { + error = defaultMessage; + } + + // Determine status code based on error type + if ("error" in apiError) { + if (apiError.error === "rate_limit_exceeded") { + errorStatus = 429; + } else if (apiError.error === "not_found") { + errorStatus = 404; + } else { + errorStatus = 500; + } + } else { + errorStatus = 500; + } + } else if (apiError instanceof Error) { + error = apiError.message; + errorStatus = 500; + } else { + error = defaultMessage; + errorStatus = 500; + } + } + async function fetchTest() { if (!testId) return; @@ -36,6 +68,9 @@ } nbfetch += 1; + // Clear any previous errors + error = null; + // Set fetching state and ensure minimum 500ms display time fetching = true; const startTime = Date.now(); @@ -52,10 +87,15 @@ } stopPolling(); } + } else if (testResponse.error) { + handleApiError(testResponse.error, "Failed to fetch test"); + loading = false; + stopPolling(); + return; } loading = false; } catch (err) { - error = err instanceof Error ? err.message : "Failed to fetch test"; + handleApiError(err, "Failed to fetch test"); loading = false; stopPolling(); } finally { @@ -107,7 +147,7 @@ if (newTestId) { testChange(newTestId); } - }) + }); onDestroy(() => { stopPolling(); @@ -124,9 +164,11 @@ const response = await reanalyzeReport({ path: { id: testId } }); if (response.data) { report = response.data; + } else if (response.error) { + handleApiError(response.error, "Failed to reanalyze report"); } } catch (err) { - error = err instanceof Error ? err.message : "Failed to reanalyze report"; + handleApiError(err, "Failed to reanalyze report"); } finally { reanalyzing = false; } @@ -162,14 +204,7 @@

    Loading test...

    {:else if error} -
    -
    - -
    -
    + {:else if test && test.status !== "analyzed"} Date: Fri, 24 Oct 2025 18:43:55 +0700 Subject: [PATCH 102/256] Add a background report sample in hero on home page Why use a report from icloud.com to illustrate this project? Among all the common email providers I tested, it achieved the best results. --- web/src/routes/+page.svelte | 41 ++++++++++++++++++++++++++++++++++-- web/static/img/report.webp | Bin 0 -> 86668 bytes 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 web/static/img/report.webp diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 8783582..f26f8e2 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -151,7 +151,7 @@ and more. Open-source, self-hosted, and privacy-focused.

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

    {#if senderIp}
    From 6d2a59dd7b7b1f824a7f08ae6c5bc6034b4b3f1c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 25 Oct 2025 03:09:40 +0000 Subject: [PATCH 114/256] Update dependency @hey-api/openapi-ts to v0.86.4 --- web/package-lock.json | 20 ++++++++++++++++---- web/package.json | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 9dc1ca6..14b0f4d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.86.3", + "@hey-api/openapi-ts": "0.86.4", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", @@ -690,9 +690,9 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.86.3", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.3.tgz", - "integrity": "sha512-hl8JYx1vSVGvPSqNSohUGfTFQu01Ib1uCmV0HUsk/ZYxHBaiKgOJNzUYOkID35lUcSh3bGcuj308s+p/+/qhVA==", + "version": "0.86.4", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.4.tgz", + "integrity": "sha512-TxQw+2IAykRrHlJwNU68rGjkuL92FhL4TDfkGCzj4dRxo+P4oiBOKSkxSNKUvolDQSdnsq1G71ynEkXoI7BJUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1225,6 +1225,7 @@ "integrity": "sha512-1v+MbMHxTi6ctQyxmz3owLKqZGaBHyx4EQqTdq/PvDswPFzw3WlqhrOKOh2ZzH23+XpQGEF9G+KDIgYJE+byvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1264,6 +1265,7 @@ "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", @@ -1341,6 +1343,7 @@ "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1391,6 +1394,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1724,6 +1728,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2324,6 +2329,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3394,6 +3400,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3433,6 +3440,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3566,6 +3574,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3900,6 +3909,7 @@ "integrity": "sha512-mP3vFFv5OUM5JN189+nJVW74kQ1dGqUrXTEzvCEVZqessY0GxZDls1nWVvt4Sxyv2USfQvAZO68VRaeIZvpzKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4090,6 +4100,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4166,6 +4177,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/web/package.json b/web/package.json index 9fd3832..8deb4f4 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", - "@hey-api/openapi-ts": "0.86.3", + "@hey-api/openapi-ts": "0.86.4", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", From faf860f4a1036226a680422a4e59f292bded6e8d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Oct 2025 10:22:32 +0700 Subject: [PATCH 115/256] New option to configure ratelimit --- internal/app/server.go | 42 +++++++++++++++++++++------------------ internal/config/cli.go | 1 + internal/config/config.go | 2 ++ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/internal/app/server.go b/internal/app/server.go index fbb7a31..7149f45 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -78,26 +78,30 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - // Set up rate limiting (1 request per second per IP) - rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ - Rate: 4 * time.Second, - Limit: 2, - }) - rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ - ErrorHandler: func(c *gin.Context, info ratelimit.Info) { - c.JSON(429, gin.H{ - "error": "rate_limit_exceeded", - "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), - }) - }, - KeyFunc: func(c *gin.Context) string { - return c.ClientIP() - }, - }) - - // Register API routes with rate limiting apiGroup := router.Group("/api") - apiGroup.Use(rateLimiter) + + if cfg.RateLimit > 0 { + // Set up rate limiting (2x to handle burst) + rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ + Rate: 2 * time.Second, + Limit: 2 * cfg.RateLimit, + }) + rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ + ErrorHandler: func(c *gin.Context, info ratelimit.Info) { + c.JSON(429, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), + }) + }, + KeyFunc: func(c *gin.Context) string { + return c.ClientIP() + }, + }) + + apiGroup.Use(rateLimiter) + } + + // Register API routes api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) diff --git a/internal/config/cli.go b/internal/config/cli.go index d19b90b..17f0ff6 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -38,6 +38,7 @@ func declareFlags(o *Config) { 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") + flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") // 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 668573a..6f35ec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,7 @@ type Config struct { Email EmailConfig Analysis AnalysisConfig ReportRetention time.Duration // How long to keep reports. 0 = keep forever + RateLimit uint // API rate limit (requests per second per IP) SurveyURL url.URL // URL for user feedback survey } @@ -71,6 +72,7 @@ func DefaultConfig() *Config { DevProxy: "", Bind: ":8080", ReportRetention: 0, // Keep reports forever by default + RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default) Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", From 07c7e63ee7e56c8233d5a70806085a24d0cf6226 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Oct 2025 11:16:48 +0700 Subject: [PATCH 116/256] Create a stores directory --- web/src/lib/components/TinySurvey.svelte | 2 +- web/src/lib/{ => stores}/config.ts | 0 web/src/routes/+page.svelte | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename web/src/lib/{ => stores}/config.ts (100%) diff --git a/web/src/lib/components/TinySurvey.svelte b/web/src/lib/components/TinySurvey.svelte index e971b80..ec5201e 100644 --- a/web/src/lib/components/TinySurvey.svelte +++ b/web/src/lib/components/TinySurvey.svelte @@ -2,7 +2,7 @@ import type { Snippet } from "svelte"; import type { ClassValue } from "svelte/elements"; - import { appConfig } from "$lib/config"; + import { appConfig } from "$lib/stores/config"; interface Props { class: ClassValue; diff --git a/web/src/lib/config.ts b/web/src/lib/stores/config.ts similarity index 100% rename from web/src/lib/config.ts rename to web/src/lib/stores/config.ts diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index f26f8e2..d354a13 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,7 +1,7 @@
    %sveltekit.body%
    diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index b76b48a..83e00ab 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -1,6 +1,7 @@
    -
    +

    @@ -104,7 +105,7 @@

    {/if} {#if authentication.iprev.details} -
    {authentication.iprev.details}
    +
    {authentication.iprev.details}
    {/if}
    @@ -128,7 +129,7 @@
    {/if} {#if authentication.spf.details} -
    {authentication.spf.details}
    +
    {authentication.spf.details}
    {/if}
    {:else} @@ -167,7 +168,7 @@
    {/if} {#if authentication.dkim[0].details} -
    {authentication.dkim[0].details}
    +
    {authentication.dkim[0].details}
    {/if}
    {:else} @@ -206,7 +207,7 @@
    {/if} {#if authentication.x_google_dkim.details} -
    {authentication.x_google_dkim.details}
    +
    {authentication.x_google_dkim.details}
    {/if}
    @@ -230,7 +231,7 @@
    {/if} {#if authentication.x_aligned_from.details} -
    {authentication.x_aligned_from.details}
    +
    {authentication.x_aligned_from.details}
    {/if}
    @@ -276,7 +277,7 @@ {/if} {/if} {#if authentication.dmarc.details} -
    {authentication.dmarc.details}
    +
    {authentication.dmarc.details}
    {/if}
    {:else} @@ -303,7 +304,7 @@ {authentication.bimi.result} {#if authentication.bimi.details} -
    {authentication.bimi.details}
    +
    {authentication.bimi.details}
    {/if}
    {:else if authentication.bimi && authentication.bimi.result == "none"} @@ -315,7 +316,7 @@
    Brand Indicators for Message Identification
    {#if authentication.bimi.details} -
    {authentication.bimi.details}
    +
    {authentication.bimi.details}
    {/if}
    {:else} @@ -345,7 +346,7 @@
    Chain length: {authentication.arc.chain_length}
    {/if} {#if authentication.arc.details} -
    {authentication.arc.details}
    +
    {authentication.arc.details}
    {/if}
    diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index f5935a1..bb0a160 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,6 +1,7 @@
    -
    +

    diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index b5fc380..87cfd5e 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -1,6 +1,7 @@
    -
    +

    diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index c325049..a871096 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,6 +1,7 @@
    -
    +

    diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte index 0f62dd2..5b5f051 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -1,4 +1,6 @@ -
    +
    +
    diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index d354a13..765b03d 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -252,6 +252,26 @@ text-shadow: black 0 0 1px; } + /* Dark mode hero adjustments */ + :global([data-bs-theme="dark"]) .hero { + background: + linear-gradient(135deg, rgba(50, 65, 140, 0.85) 0%, rgba(65, 40, 95, 0.9) 100%), + url("/img/report.webp"); + background-size: cover; + background-position: center 25%; + background-repeat: no-repeat; + } + + :global([data-bs-theme="dark"]) .hero h1, + :global([data-bs-theme="dark"]) .hero p { + text-shadow: black 0 0 3px; + } + + /* Dark mode section background */ + :global([data-bs-theme="dark"]) #steps { + background-color: var(--bs-secondary-bg) !important; + } + .fade-in { animation: fadeIn 0.6s ease-out; } @@ -293,4 +313,21 @@ box-shadow: 0 1rem 2rem rgba(25, 135, 84, 0.5); } } + + @keyframes pulse-dark { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); + } + 50% { + transform: scale(1.08); + box-shadow: 0 1rem 2rem rgba(25, 135, 84, 0.7); + } + } + + /* Dark mode pulse animation */ + :global([data-bs-theme="dark"]) .cta-button { + animation: pulse-dark 2s infinite; + } From b95e5d67327a6954864d2fe84aaecb6b7c5aff8c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Oct 2025 18:48:56 +0700 Subject: [PATCH 118/256] Add a favicon --- web/src/lib/assets/favicon.svg | 99 ++++++++++++++++++++++++++++++++++ web/src/routes/+layout.svelte | 6 +++ 2 files changed, 105 insertions(+) create mode 100644 web/src/lib/assets/favicon.svg diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..fb235b0 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + h + + + + + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index bf7bbd5..21d3ec9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -3,6 +3,8 @@ import "bootstrap-icons/font/bootstrap-icons.css"; import "../app.css"; + import favicon from '$lib/assets/favicon.svg'; + import Logo from "$lib/components/Logo.svelte"; import { theme } from "$lib/stores/theme"; import { onMount } from "svelte"; @@ -22,6 +24,10 @@ } + + + +
    diff --git a/web/src/routes/blacklist/+page.svelte b/web/src/routes/blacklist/+page.svelte new file mode 100644 index 0000000..f104e73 --- /dev/null +++ b/web/src/routes/blacklist/+page.svelte @@ -0,0 +1,186 @@ + + + + Blacklist Check - happyDeliver + + +
    +
    +
    + +
    +

    + + Check IP Blacklist Status +

    +

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

    +
    + + +
    +
    +

    Enter IP Address

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

    + + What's Checked +

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

    + + Why Check Blacklists? +

    +

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

    +

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

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

    + + Need Complete Email Analysis? +

    +

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

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

    + + Blacklist Analysis +

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

    Checking {ip}...

    +

    Querying DNS-based blacklists

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

    Check Failed

    +

    {error}

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

    + {result.ip} +

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

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

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

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

    +
    + {/if} +
    +
    +
    + + Blacklist Score +
    +
    +
    +
    +
    + + + + + +
    +
    +

    + + What This Means +

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

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

    + {:else} +

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

    +
    +

    Recommended Actions:

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

    + + Want Complete Email Analysis? +

    +

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

    + + + Send Test Email + +
    +
    +
    + {/if} +
    +
    +
    + + From 723166936246d3e7db838bcca766f9d73c6346ed Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 Oct 2025 11:11:36 +0700 Subject: [PATCH 140/256] Add survey on RBL report and Domain report page --- web/src/routes/blacklist/[ip]/+page.svelte | 8 +++++++- web/src/routes/domain/[domain]/+page.svelte | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte index 4556552..1516751 100644 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ b/web/src/routes/blacklist/[ip]/+page.svelte @@ -3,7 +3,7 @@ import { onMount } from "svelte"; import { checkBlacklist } from "$lib/api"; import type { BlacklistCheckResponse } from "$lib/api/types.gen"; - import { GradeDisplay, BlacklistCard } from "$lib/components"; + import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components"; import { theme } from "$lib/stores/theme"; let ip = $derived($page.params.ip); @@ -129,6 +129,12 @@

    +
    + +

    diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte index 7ce9ee4..424b848 100644 --- a/web/src/routes/domain/[domain]/+page.svelte +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -3,7 +3,7 @@ import { onMount } from "svelte"; import { testDomain } from "$lib/api"; import type { DomainTestResponse } from "$lib/api/types.gen"; - import { GradeDisplay, DnsRecordsCard } from "$lib/components"; + import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components"; import { theme } from "$lib/stores/theme"; let domain = $derived($page.params.domain); @@ -124,6 +124,12 @@

    +
    + +
    From 3b301a415fa91f250adf4c8a94ea000679be6f66 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 15:46:48 +0700 Subject: [PATCH 141/256] Protonmail is now the best mailbox provider I tested --- web/static/img/report.webp | Bin 86668 -> 85254 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/web/static/img/report.webp b/web/static/img/report.webp index 97c3b8c416e2cbf4158a26f81f196d51e9fd3d23..d3df7a93bd2c1df1d755285eb1f024ef4785ea28 100644 GIT binary patch literal 85254 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

    AmC0+yaX!S$hD!{Jc# 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 z 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 literal 86668 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

    7NK}hT>rnJZ+Nq6AulYk97ZAPC>K=DP^3Z4F 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 Date: Sat, 1 Nov 2025 17:52:28 +0700 Subject: [PATCH 142/256] Handle all options of x-aligned-from --- api/openapi.yaml | 2 +- web/src/lib/components/AuthenticationCard.svelte | 12 ++++++++++++ web/src/lib/components/HeaderAnalysisCard.svelte | 7 +------ web/src/routes/test/[test]/+page.svelte | 1 - 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 92bf3e3..25c1b90 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -760,7 +760,7 @@ components: properties: result: type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined] + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass] description: Authentication result example: "pass" domain: diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 0b36dd0..8f22eac 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -16,10 +16,16 @@ function getAuthResultClass(result: string, noneIsFail: boolean): string { switch (result) { case "pass": + case "domain_pass": + case "orgdomain_pass": return "text-success"; + case "error": case "fail": case "missing": case "invalid": + case "null": + case "null_smtp": + case "null_header": return "text-danger"; case "softfail": case "neutral": @@ -36,12 +42,18 @@ function getAuthResultIcon(result: string, noneIsFail: boolean): string { switch (result) { case "pass": + case "domain_pass": + case "orgdomain_pass": return "bi-check-circle-fill"; case "fail": return "bi-x-circle-fill"; case "softfail": case "neutral": case "invalid": + case "null": + case "error": + case "null_smtp": + case "null_header": return "bi-exclamation-circle-fill"; case "missing": return "bi-dash-circle-fill"; diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 36e173b..306260e 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -9,7 +9,6 @@ headerAnalysis: HeaderAnalysis; headerGrade?: string; headerScore?: number; - xAlignedFrom?: AuthResult; } let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props(); @@ -62,11 +61,7 @@

    - {#if xAlignedFrom} - - {:else} - - {/if} + Domain Alignment
    diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 82ff49d..8e78be7 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -335,7 +335,6 @@ headerAnalysis={report.header_analysis} headerGrade={report.summary?.header_grade} headerScore={report.summary?.header_score} - xAlignedFrom={report.authentication?.x_aligned_from} />
    From 1c4eb0653ed7e72ab6404e98cb35fbd268ebf2c2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 17:57:57 +0700 Subject: [PATCH 143/256] Don't alert on missing -all on included SPF records --- pkg/analyzer/dns_spf.go | 39 +++++++++++--------- pkg/analyzer/dns_spf_test.go | 71 +++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go index fa819c1..a6b74c1 100644 --- a/pkg/analyzer/dns_spf.go +++ b/pkg/analyzer/dns_spf.go @@ -33,11 +33,12 @@ import ( // checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { visited := make(map[string]bool) - return d.resolveSPFRecords(domain, visited, 0) + return d.resolveSPFRecords(domain, visited, 0, true) } // resolveSPFRecords recursively resolves SPF records including include: directives -func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord { const maxDepth = 10 // Prevent infinite recursion if depth > maxDepth { @@ -103,7 +104,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, } // Basic validation - validationErr := d.validateSPF(spfRecord) + validationErr := d.validateSPF(spfRecord, isMainRecord) // Extract the "all" mechanism qualifier var allQualifier *api.SPFRecordAllQualifier @@ -140,7 +141,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, if redirectDomain != "" { // redirect= replaces the current domain's policy entirely // Only follow if no other mechanisms matched (per RFC 7208) - redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false) if redirectRecords != nil { results = append(results, *redirectRecords...) } @@ -150,7 +151,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, // Extract and resolve include: directives includes := d.extractSPFIncludes(spfRecord) for _, includeDomain := range includes { - includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false) if includedRecords != nil { results = append(results, *includedRecords...) } @@ -236,7 +237,8 @@ func (d *DNSAnalyzer) isValidSPFMechanism(token string) error { } // validateSPF performs basic SPF record validation -func (d *DNSAnalyzer) validateSPF(record string) error { +// isMainRecord indicates if this is the primary domain's record (not an included one) +func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error { // Must start with v=spf1 if !strings.HasPrefix(record, "v=spf1") { return fmt.Errorf("SPF record must start with 'v=spf1'") @@ -269,19 +271,22 @@ func (d *DNSAnalyzer) validateSPF(record string) error { return nil } - // Check for common syntax issues - // Should have a final mechanism (all, +all, -all, ~all, ?all) - validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} - hasValidEnding := false - for _, ending := range validEndings { - if strings.HasSuffix(record, ending) { - hasValidEnding = true - break + // Only check for 'all' mechanism on the main record, not on included records + if isMainRecord { + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } } - } - if !hasValidEnding { - return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier") + if !hasValidEnding { + return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier") + } } return nil diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go index bc51a6f..b1195cb 100644 --- a/pkg/analyzer/dns_spf_test.go +++ b/pkg/analyzer/dns_spf_test.go @@ -128,7 +128,8 @@ func TestValidateSPF(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := analyzer.validateSPF(tt.record) + // Test as main record (isMainRecord = true) since these tests check overall SPF validity + err := analyzer.validateSPF(tt.record, true) if tt.expectError { if err == nil { t.Errorf("validateSPF(%q) expected error but got nil", tt.record) @@ -144,6 +145,74 @@ func TestValidateSPF(t *testing.T) { } } +func TestValidateSPF_IncludedRecords(t *testing.T) { + tests := []struct { + name string + record string + isMainRecord bool + expectError bool + errorMsg string + }{ + { + name: "Main record without 'all' - should error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record without 'all' - should NOT error", + record: "v=spf1 include:_spf.example.com", + isMainRecord: false, + expectError: false, + }, + { + name: "Included record with only mechanisms - should NOT error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with only mechanisms - should error", + record: "v=spf1 ip4:192.0.2.0/24 mx", + isMainRecord: true, + expectError: true, + errorMsg: "should end with an 'all' mechanism", + }, + { + name: "Included record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: false, + expectError: false, + }, + { + name: "Main record with 'all' - valid", + record: "v=spf1 ip4:192.0.2.0/24 -all", + isMainRecord: true, + expectError: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := analyzer.validateSPF(tt.record, tt.isMainRecord) + if tt.expectError { + if err == nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err) + } + } + }) + } +} + func TestExtractSPFRedirect(t *testing.T) { tests := []struct { name string From d870fc81306ccf33fc9f1259be2d8315f1dd16c0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 2 Nov 2025 10:36:17 +0700 Subject: [PATCH 144/256] Add backup/restore commands --- cmd/happyDeliver/main.go | 24 ++++-- internal/app/cli_backup.go | 156 ++++++++++++++++++++++++++++++++++++ internal/storage/storage.go | 30 +++++++ 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 internal/app/cli_backup.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index af1d30f..3caf4d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -33,8 +33,8 @@ import ( ) func main() { - fmt.Println("happyDeliver - Email Deliverability Testing Platform") - fmt.Printf("Version: %s\n", version.Version) + fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform") + fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version) cfg, err := config.ConsolidateConfig() if err != nil { @@ -52,6 +52,18 @@ func main() { if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { log.Fatalf("Analyzer error: %v", err) } + case "backup": + if err := app.RunBackup(cfg); err != nil { + log.Fatalf("Backup error: %v", err) + } + case "restore": + inputFile := "" + if len(flag.Args()) >= 2 { + inputFile = flag.Args()[1] + } + if err := app.RunRestore(cfg, inputFile); err != nil { + log.Fatalf("Restore error: %v", err) + } case "version": fmt.Println(version.Version) default: @@ -63,9 +75,11 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") + fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..4b01fbb --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,156 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + fmt.Fprintln(os.Stderr, "Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) + fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) + failed++ + continue + } + + if exists { + fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) + failed++ + continue + } + + restored++ + } + + fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 35aa0df..39b2eb6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -147,3 +147,33 @@ func (s *DBStorage) Close() error { } return sqlDB.Close() } + +// GetAllReports retrieves all reports from the database +func GetAllReports(s Storage) ([]Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support GetAllReports") + } + + var reports []Report + if err := dbStorage.db.Find(&reports).Error; err != nil { + return nil, fmt.Errorf("failed to retrieve reports: %w", err) + } + + return reports, nil +} + +// CreateReportFromBackup creates a report from backup data, preserving timestamps +func CreateReportFromBackup(s Storage, report *Report) (*Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support CreateReportFromBackup") + } + + // Use Create to insert the report with all fields including timestamps + if err := dbStorage.db.Create(report).Error; err != nil { + return nil, fmt.Errorf("failed to create report from backup: %w", err) + } + + return report, nil +} From 465da6d16a08e7d4f252a18b234b073cb8e48bb6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 3 Nov 2025 13:22:40 +0700 Subject: [PATCH 145/256] Don't look at original DKIM keys headers --- pkg/analyzer/authentication.go | 7 - pkg/analyzer/authentication_dkim.go | 34 ---- pkg/analyzer/authentication_dkim_test.go | 244 ----------------------- 3 files changed, 285 deletions(-) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 02f8b28..07f7794 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -50,13 +50,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results.Spf = a.parseLegacySPF(email) } - if results.Dkim == nil || len(*results.Dkim) == 0 { - dkimResults := a.parseLegacyDKIM(email) - if len(dkimResults) > 0 { - results.Dkim = &dkimResults - } - } - // Parse ARC headers if not already parsed from Authentication-Results if results.Arc == nil { results.Arc = a.parseARCHeaders(email) diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go index 9f1774b..b6cf5f8 100644 --- a/pkg/analyzer/authentication_dkim.go +++ b/pkg/analyzer/authentication_dkim.go @@ -59,40 +59,6 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { return result } -// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header -func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { - var results []api.AuthResult - - // Get all DKIM-Signature headers - dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] - for _, dkimHeader := range dkimHeaders { - result := api.AuthResult{ - Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone - } - - // Extract domain (d=) - domainRe := regexp.MustCompile(`d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (s=) - selectorRe := regexp.MustCompile(`s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - details := "DKIM signature present (verification status unknown)" - result.Details = &details - - results = append(results, result) - } - - return results -} - func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { // Expect at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 323e421..2aab530 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -22,7 +22,6 @@ package analyzer import ( - "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -85,246 +84,3 @@ func TestParseDKIMResult(t *testing.T) { }) } } - -func TestParseLegacyDKIM(t *testing.T) { - tests := []struct { - name string - dkimSignatures []string - expectedCount int - expectedDomains []string - expectedSelector []string - }{ - { - name: "Single DKIM signature with domain and selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "Multiple DKIM signatures", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", - "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "example.com"}, - expectedSelector: []string{"selector1", "selector2"}, - }, - { - name: "DKIM signature with different domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", - }, - expectedCount: 1, - expectedDomains: []string{"mail.example.org"}, - expectedSelector: []string{"default"}, - }, - { - name: "DKIM signature with subdomain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", - }, - expectedCount: 1, - expectedDomains: []string{"newsletters.example.com"}, - expectedSelector: []string{"marketing"}, - }, - { - name: "Multiple signatures from different domains", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", - "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "relay.com"}, - expectedSelector: []string{"s1", "s2"}, - }, - { - name: "No DKIM signatures", - dkimSignatures: []string{}, - expectedCount: 0, - expectedDomains: []string{}, - expectedSelector: []string{}, - }, - { - name: "DKIM signature without selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{""}, - }, - { - name: "DKIM signature without domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; s=selector1; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{""}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with whitespace in parameters", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with multiline format", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with ed25519 algorithm", - dkimSignatures: []string{ - "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"ed25519"}, - }, - { - name: "Complex real-world DKIM signature", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", - }, - expectedCount: 1, - expectedDomains: []string{"google.com"}, - expectedSelector: []string{"20230601"}, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with DKIM-Signature headers - email := &EmailMessage{ - Header: make(map[string][]string), - } - if len(tt.dkimSignatures) > 0 { - email.Header["Dkim-Signature"] = tt.dkimSignatures - } - - results := analyzer.parseLegacyDKIM(email) - - // Check count - if len(results) != tt.expectedCount { - t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) - return - } - - // Check each result - for i, result := range results { - // All legacy DKIM results should have Result = none - if result.Result != api.AuthResultResultNone { - t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) - } - - // Check domain - if i < len(tt.expectedDomains) { - expectedDomain := tt.expectedDomains[i] - if expectedDomain != "" { - if result.Domain == nil { - t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) - } else if strings.TrimSpace(*result.Domain) != expectedDomain { - t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) - } - } - } - - // Check selector - if i < len(tt.expectedSelector) { - expectedSelector := tt.expectedSelector[i] - if expectedSelector != "" { - if result.Selector == nil { - t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) - } else if strings.TrimSpace(*result.Selector) != expectedSelector { - t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) - } - } - } - - // Check that Details is set - if result.Details == nil { - t.Errorf("Result[%d].Details = nil, expected non-nil", i) - } else { - expectedDetails := "DKIM signature present (verification status unknown)" - if *result.Details != expectedDetails { - t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) - } - } - } - }) - } -} - -func TestParseLegacyDKIM_Integration(t *testing.T) { - hostname = "" - - // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication - t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultNone { - t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { - t.Error("Expected domain to be 'example.com'") - } - }) - - t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - // Both Authentication-Results and DKIM-Signature headers - email.Header["Authentication-Results"] = []string{ - "mx.example.com; dkim=pass header.d=verified.com header.s=s1", - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { - t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { - t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") - } - }) -} From 5b179e7b93ba8d277eca793699b2acccf8d74617 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 3 Nov 2025 14:58:48 +0700 Subject: [PATCH 146/256] Domain alignment checks for DKIM --- api/openapi.yaml | 20 +- pkg/analyzer/headers.go | 119 +++++++++-- pkg/analyzer/headers_test.go | 160 ++++++++++++++- pkg/analyzer/report.go | 2 +- .../lib/components/HeaderAnalysisCard.svelte | 192 +++++++++++++----- 5 files changed, 410 insertions(+), 83 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 25c1b90..8463007 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -664,6 +664,21 @@ components: description: Reverse DNS (PTR record) for the IP address example: "mail.example.com" + DKIMDomainInfo: + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + DomainAlignment: type: object properties: @@ -686,9 +701,8 @@ components: dkim_domains: type: array items: - type: string - description: Domains from DKIM signatures - example: ["example.com"] + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains aligned: type: boolean description: Whether all domains align (strict alignment - exact match) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 7e65571..b7ff3bb 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade := 6 headers := *analysis.Headers - // RP and From alignment (20 points) - if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { - score += 20 - } else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned { - score += 15 - } else { + // RP and From alignment (25 points) + if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned { + // Bad domain alignment, cap grade to C maxGrade -= 2 + } else if *analysis.DomainAlignment.Aligned { + score += 25 + } else if *analysis.DomainAlignment.RelaxedAligned { + score += 20 } // Check required headers (RFC 5322) - 30 points @@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade = 1 } - // Check recommended headers (20 points) + // Check recommended headers (15 points) recommendedHeaders := []string{"subject", "to"} // Add reply-to when from is a no-reply address @@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int presentRecommended++ } } - score += presentRecommended * 20 / recommendedCount + score += presentRecommended * 15 / recommendedCount if presentRecommended < recommendedCount { maxGrade -= 1 @@ -235,7 +236,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string { } // GenerateHeaderAnalysis creates structured header analysis from email -func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis { +func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis { if email == nil { return nil } @@ -281,7 +282,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header } // Domain alignment - domainAlignment := h.analyzeDomainAlignment(email) + domainAlignment := h.analyzeDomainAlignment(email, authResults) if domainAlignment != nil { analysis.DomainAlignment = domainAlignment } @@ -352,8 +353,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp return check } -// analyzeDomainAlignment checks domain alignment between headers -func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { +// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures +func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment { alignment := &api.DomainAlignment{ Aligned: api.PtrTo(true), RelaxedAligned: api.PtrTo(true), @@ -383,14 +384,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain } } + // Extract DKIM domains from authentication results + var dkimDomains []api.DKIMDomainInfo + if authResults != nil && authResults.Dkim != nil { + for _, dkim := range *authResults.Dkim { + if dkim.Domain != nil && *dkim.Domain != "" { + domain := *dkim.Domain + orgDomain := h.getOrganizationalDomain(domain) + dkimDomains = append(dkimDomains, api.DKIMDomainInfo{ + Domain: domain, + OrgDomain: orgDomain, + }) + } + } + } + if len(dkimDomains) > 0 { + alignment.DkimDomains = &dkimDomains + } + // Check alignment (strict and relaxed) issues := []string{} - if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { + + // hasReturnPath and hasDKIM track whether we have these fields to check + hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil + hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0 + + // If neither Return-Path nor DKIM is present, keep default alignment (true) + // Otherwise, at least one must be aligned for overall alignment to be true + strictAligned := !hasReturnPath && !hasDKIM + relaxedAligned := !hasReturnPath && !hasDKIM + + // Check Return-Path alignment + rpStrictAligned := false + rpRelaxedAligned := false + if hasReturnPath { fromDomain := *alignment.FromDomain rpDomain := *alignment.ReturnPathDomain // Strict alignment: exact match (case-insensitive) - strictAligned := strings.EqualFold(fromDomain, rpDomain) + rpStrictAligned = strings.EqualFold(fromDomain, rpDomain) // Relaxed alignment: organizational domain match var fromOrgDomain, rpOrgDomain string @@ -400,20 +432,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain if alignment.ReturnPathOrgDomain != nil { rpOrgDomain = *alignment.ReturnPathOrgDomain } - relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) + rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain) - *alignment.Aligned = strictAligned - *alignment.RelaxedAligned = relaxedAligned - - if !strictAligned { - if relaxedAligned { + if !rpStrictAligned { + if rpRelaxedAligned { issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain)) } else { issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) } } + + strictAligned = rpStrictAligned + relaxedAligned = rpRelaxedAligned } + // Check DKIM alignment + dkimStrictAligned := false + dkimRelaxedAligned := false + if hasDKIM { + fromDomain := *alignment.FromDomain + var fromOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + + for _, dkimDomain := range dkimDomains { + // Check strict alignment for this DKIM signature + if strings.EqualFold(fromDomain, dkimDomain.Domain) { + dkimStrictAligned = true + } + + // Check relaxed alignment for this DKIM signature + if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) { + dkimRelaxedAligned = true + } + } + + if !dkimStrictAligned && !dkimRelaxedAligned { + // List all DKIM domains that failed alignment + dkimDomainsList := []string{} + for _, dkimDomain := range dkimDomains { + dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain) + } + issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain)) + } else if !dkimStrictAligned && dkimRelaxedAligned { + // DKIM has relaxed alignment but not strict + issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain)) + } + + // Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned + // For DMARC compliance, at least one of SPF or DKIM must be aligned + if dkimStrictAligned { + strictAligned = true + } + if dkimRelaxedAligned { + relaxedAligned = true + } + } + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + if len(issues) > 0 { alignment.Issues = &issues } diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 7896a5c..6a35d18 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -24,6 +24,7 @@ package analyzer import ( "net/mail" "net/textproto" + "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Generate header analysis first - analysis := analyzer.GenerateHeaderAnalysis(tt.email) + analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil) score, _ := analyzer.CalculateHeaderScore(analysis) if score < tt.minScore || score > tt.maxScore { t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) @@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) { }), } - alignment := analyzer.analyzeDomainAlignment(email) + alignment := analyzer.analyzeDomainAlignment(email, nil) if alignment == nil { t.Fatal("Expected non-nil alignment") @@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { "from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000", } - analysis := analyzer.GenerateHeaderAnalysis(email) + analysis := analyzer.GenerateHeaderAnalysis(email, nil) if analysis == nil { t.Fatal("GenerateHeaderAnalysis returned nil") @@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool { } return *a == *b } + +func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) { + tests := []struct { + name string + fromHeader string + returnPath string + dkimDomains []string + expectStrictAligned bool + expectRelaxedAligned bool + expectIssuesContain string + }{ + { + name: "DKIM strict alignment with From domain", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "DKIM relaxed alignment only", + fromHeader: "sender@mail.example.com", + returnPath: "", + dkimDomains: []string{"example.com"}, + expectStrictAligned: false, + expectRelaxedAligned: true, + expectIssuesContain: "relaxed alignment", + }, + { + name: "DKIM no alignment", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not align", + }, + { + name: "Multiple DKIM signatures - one aligns", + fromHeader: "sender@example.com", + returnPath: "", + dkimDomains: []string{"different.com", "example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Return-Path misaligned but DKIM aligned", + fromHeader: "sender@example.com", + returnPath: "bounce@different.com", + dkimDomains: []string{"example.com"}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "Return-Path", + }, + { + name: "Return-Path aligned, no DKIM", + fromHeader: "sender@example.com", + returnPath: "bounce@example.com", + dkimDomains: []string{}, + expectStrictAligned: true, + expectRelaxedAligned: true, + expectIssuesContain: "", + }, + { + name: "Both Return-Path and DKIM misaligned", + fromHeader: "sender@example.com", + returnPath: "bounce@other.com", + dkimDomains: []string{"different.com"}, + expectStrictAligned: false, + expectRelaxedAligned: false, + expectIssuesContain: "do not", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": tt.fromHeader, + "Return-Path": tt.returnPath, + }), + } + + // Create authentication results with DKIM signatures + var authResults *api.AuthenticationResults + if len(tt.dkimDomains) > 0 { + dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains)) + for _, domain := range tt.dkimDomains { + dkimResults = append(dkimResults, api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: &domain, + }) + } + authResults = &api.AuthenticationResults{ + Dkim: &dkimResults, + } + } + + alignment := analyzer.analyzeDomainAlignment(email, authResults) + + if alignment == nil { + t.Fatal("Expected non-nil alignment") + } + + if alignment.Aligned == nil { + t.Fatal("Expected non-nil Aligned field") + } + + if *alignment.Aligned != tt.expectStrictAligned { + t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned) + } + + if alignment.RelaxedAligned == nil { + t.Fatal("Expected non-nil RelaxedAligned field") + } + + if *alignment.RelaxedAligned != tt.expectRelaxedAligned { + t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned) + } + + // Check DKIM domains are populated + if len(tt.dkimDomains) > 0 { + if alignment.DkimDomains == nil { + t.Error("Expected DkimDomains to be populated") + } else if len(*alignment.DkimDomains) != len(tt.dkimDomains) { + t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains)) + } + } + + // Check issues contain expected string + if tt.expectIssuesContain != "" { + if alignment.Issues == nil || len(*alignment.Issues) == 0 { + t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain) + } else { + found := false + for _, issue := range *alignment.Issues { + if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) { + found = true + break + } + } + if !found { + t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues) + } + } + } + }) + } +} diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index a39a98a..39871fe 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -75,7 +75,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) - results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email) + results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 306260e..3cfe287 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -66,68 +66,148 @@
    -

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

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

    -
    -
    - Strict Alignment -
    - - - {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} - -
    -
    Exact domain match
    +
    +
    +
    +
    + SPF
    -
    - Relaxed Alignment -
    - - - {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} - -
    -
    Organizational domain match
    -
    -
    - From Domain -
    {headerAnalysis.domain_alignment.from_domain || '-'}
    - {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    - {/if} -
    -
    - Return-Path Domain -
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    - {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} -
    Org: {headerAnalysis.domain_alignment.return_path_org_domain}
    - {/if} -
    -
    - {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
    - {#each headerAnalysis.domain_alignment.issues as issue} -
    - - {issue} +
    +
    + Strict Alignment +
    + + + {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} +
    - {/each} +
    Exact domain match
    +
    +
    + Relaxed Alignment +
    + + + {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} + +
    +
    Organizational domain match
    +
    +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    + {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    + {/if} +
    +
    + Return-Path Domain +
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    + {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
    Org: {headerAnalysis.domain_alignment.return_path_org_domain}
    + {/if} +
    - {/if} + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
    + {#each headerAnalysis.domain_alignment.issues as issue} +
    + + {issue} +
    + {/each} +
    + {/if} - - {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} -
    - {#if dmarcRecord.spf_alignment === 'strict'} - - Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. - {:else} - - Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. - {/if} + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
    + {#if dmarcRecord.spf_alignment === 'strict'} + + Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. + {/if} +
    + {/if} +
    + + {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} + {@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain} + {@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
    +
    + DKIM +
    +
    +
    +
    + Strict Alignment +
    + + + {dkim_aligned ? 'Pass' : 'Fail'} + +
    +
    Exact domain match
    +
    +
    + Relaxed Alignment +
    + + + {dkim_relaxed_aligned ? 'Pass' : 'Fail'} + +
    +
    Organizational domain match
    +
    +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    + {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    + {/if} +
    +
    + Signature Domain +
    {dkim_domain.domain || '-'}
    + {#if dkim_domain.domain !== dkim_domain.org_domain} +
    Org: {dkim_domain.org_domain}
    + {/if} +
    +
    + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
    + {#each headerAnalysis.domain_alignment.issues as issue} +
    + + {issue} +
    + {/each} +
    + {/if} + + + {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} + {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
    + {#if dmarcRecord.dkim_alignment === 'strict'} + + Strict DKIM alignment required — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment. + {:else} + + Relaxed DKIM alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass. + {/if} +
    + {/if} + {/if} +
    - {/if} + {/each}
    {/if} From c52a3aa8a769bf9d2e635aa10e6987e24ecc2bc6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 3 Nov 2025 14:59:18 +0700 Subject: [PATCH 147/256] Improve DMARC description --- web/src/lib/components/DmarcRecordDisplay.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 09a10c7..b7a3e7b 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -34,9 +34,10 @@

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


    From deb9fd4f512565615ae9d2e017f7e899c80afc37 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Nov 2025 14:09:05 +0700 Subject: [PATCH 148/256] Handle RFC6652 Closes: https://framagit.org/happyDomain/happydeliver/-/issues/1 --- pkg/analyzer/dns_spf.go | 8 ++++++-- pkg/analyzer/dns_spf_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go index a6b74c1..bfa1640 100644 --- a/pkg/analyzer/dns_spf.go +++ b/pkg/analyzer/dns_spf.go @@ -191,8 +191,12 @@ func (d *DNSAnalyzer) isValidSPFMechanism(token string) error { // Check if it's a modifier (contains =) if strings.Contains(mechanism, "=") { - // Only allow known modifiers: redirect= and exp= - if strings.HasPrefix(mechanism, "redirect=") || strings.HasPrefix(mechanism, "exp=") { + // Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=) + if strings.HasPrefix(mechanism, "redirect=") || + strings.HasPrefix(mechanism, "exp=") || + strings.HasPrefix(mechanism, "ra=") || + strings.HasPrefix(mechanism, "rp=") || + strings.HasPrefix(mechanism, "rr=") { return nil } diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go index b1195cb..2e794ce 100644 --- a/pkg/analyzer/dns_spf_test.go +++ b/pkg/analyzer/dns_spf_test.go @@ -122,6 +122,31 @@ func TestValidateSPF(t *testing.T) { expectError: true, errorMsg: "unknown modifier", }, + { + name: "Valid SPF with RFC 6652 ra modifier", + record: "v=spf1 mx ra=postmaster -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rp modifier", + record: "v=spf1 mx rp=100 -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 rr modifier", + record: "v=spf1 mx rr=all -all", + expectError: false, + }, + { + name: "Valid SPF with all RFC 6652 modifiers", + record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all", + expectError: false, + }, + { + name: "Valid SPF with RFC 6652 modifiers and redirect", + record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com", + expectError: false, + }, } analyzer := NewDNSAnalyzer(5 * time.Second) From 18c86225137b5d01291d8e612bbddd4de620dced Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Nov 2025 14:22:58 +0700 Subject: [PATCH 149/256] Don't require docker-compose to build the image, use docker hub published --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 87521ef..9071f16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - image: happydeliver:latest + image: happydomain/happydeliver:latest container_name: happydeliver hostname: mail.happydeliver.local From c91ab96642451cf25e4102922a6a45b0bd0b0101 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Nov 2025 14:23:29 +0700 Subject: [PATCH 150/256] Include the HEALTHCHECK command in Dockerfile --- Dockerfile | 4 ++++ docker-compose.yml | 7 ------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5cb9c9e..93ae993 100644 --- a/Dockerfile +++ b/Dockerfile @@ -170,6 +170,10 @@ ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happyde # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1 + # Set entrypoint ENTRYPOINT ["/entrypoint.sh"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/docker-compose.yml b/docker-compose.yml index 9071f16..fa27c5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,13 +26,6 @@ services: restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - volumes: data: logs: From 2172603ad58009cb3c7fca3efe6372206231d91a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Nov 2025 15:14:15 +0700 Subject: [PATCH 151/256] content: Add spaces behind each node to reduce gap with plain text --- pkg/analyzer/content.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 87c423f..4a3b5b8 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -627,7 +627,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { var extract func(*html.Node) extract = func(n *html.Node) { if n.Type == html.TextNode { - text.WriteString(n.Data) + text.WriteString(" " + n.Data) } // Skip script and style tags if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") { @@ -639,7 +639,7 @@ func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { } extract(doc) - return text.String() + return strings.TrimSpace(text.String()) } // calculateTextPlainConsistency compares plain text and HTML versions From 447a666ae7d560ee565b442d3c8d869fc55c27e4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Nov 2025 17:07:31 +0700 Subject: [PATCH 152/256] Fix Domain Alignment align issue when error messages --- .../lib/components/HeaderAnalysisCard.svelte | 222 +++++++++--------- 1 file changed, 109 insertions(+), 113 deletions(-) diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 3cfe287..e0ecb58 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -58,6 +58,8 @@ {/if} {#if headerAnalysis.domain_alignment} + {@const spfStrictAligned = headerAnalysis.domain_alignment.from_domain === headerAnalysis.domain_alignment.return_path_domain} + {@const spfRelaxedAligned = headerAnalysis.domain_alignment.from_org_domain === headerAnalysis.domain_alignment.return_path_org_domain}
    @@ -69,71 +71,73 @@

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

    + {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} +
    + {#each headerAnalysis.domain_alignment.issues as issue} +
    + + {issue} +
    + {/each} +
    + {/if}
    SPF
    -
    -
    - Strict Alignment -
    - - - {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'} - -
    -
    Exact domain match
    -
    -
    - Relaxed Alignment -
    - - - {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} - -
    -
    Organizational domain match
    -
    -
    - From Domain -
    {headerAnalysis.domain_alignment.from_domain || '-'}
    - {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    - {/if} -
    -
    - Return-Path Domain -
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    - {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} -
    Org: {headerAnalysis.domain_alignment.return_path_org_domain}
    - {/if} -
    -
    - {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
    - {#each headerAnalysis.domain_alignment.issues as issue} -
    - - {issue} +
    +
    +
    + Strict Alignment +
    + + + {spfStrictAligned ? 'Pass' : 'Fail'} +
    - {/each} +
    Exact domain match
    +
    +
    + Relaxed Alignment +
    + + + {spfRelaxedAligned ? 'Pass' : 'Fail'} + +
    +
    Organizational domain match
    +
    +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    + {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    + {/if} +
    +
    + Return-Path Domain +
    {headerAnalysis.domain_alignment.return_path_domain || '-'}
    + {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
    Org: {headerAnalysis.domain_alignment.return_path_org_domain}
    + {/if} +
    - {/if} - - {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} -
    - {#if dmarcRecord.spf_alignment === 'strict'} - - Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. - {:else} - - Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. - {/if} -
    - {/if} + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
    + {#if dmarcRecord.spf_alignment === 'strict'} + + Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. + {/if} +
    + {/if} +
    {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} @@ -144,67 +148,59 @@ DKIM
    -
    -
    - Strict Alignment -
    - - - {dkim_aligned ? 'Pass' : 'Fail'} - -
    -
    Exact domain match
    -
    -
    - Relaxed Alignment -
    - - - {dkim_relaxed_aligned ? 'Pass' : 'Fail'} - -
    -
    Organizational domain match
    -
    -
    - From Domain -
    {headerAnalysis.domain_alignment.from_domain || '-'}
    - {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    - {/if} -
    -
    - Signature Domain -
    {dkim_domain.domain || '-'}
    - {#if dkim_domain.domain !== dkim_domain.org_domain} -
    Org: {dkim_domain.org_domain}
    - {/if} -
    -
    - {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
    - {#each headerAnalysis.domain_alignment.issues as issue} -
    - - {issue} +
    +
    +
    + Strict Alignment +
    + + + {dkim_aligned ? 'Pass' : 'Fail'} +
    - {/each} -
    - {/if} - - - {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} - {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} -
    - {#if dmarcRecord.dkim_alignment === 'strict'} - - Strict DKIM alignment required — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment. - {:else} - - Relaxed DKIM alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass. +
    Exact domain match
    +
    +
    + Relaxed Alignment +
    + + + {dkim_relaxed_aligned ? 'Pass' : 'Fail'} + +
    +
    Organizational domain match
    +
    +
    + From Domain +
    {headerAnalysis.domain_alignment.from_domain || '-'}
    + {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
    Org: {headerAnalysis.domain_alignment.from_org_domain}
    {/if}
    +
    + Signature Domain +
    {dkim_domain.domain || '-'}
    + {#if dkim_domain.domain !== dkim_domain.org_domain} +
    Org: {dkim_domain.org_domain}
    + {/if} +
    +
    + + + {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} + {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} +
    + {#if dmarcRecord.dkim_alignment === 'strict'} + + Strict DKIM alignment required — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment. + {:else} + + Relaxed DKIM alignment allowed — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass. + {/if} +
    + {/if} {/if} - {/if} +
    {/each} From 644dfda2232b9d1c301e5411b5ded813d849f45d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 13 Nov 2025 10:53:59 +0700 Subject: [PATCH 153/256] Don't stop polling report if response is not ok Bug: https://github.com/happyDomain/happydeliver/issues/2 --- web/src/routes/test/[test]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 8e78be7..054dc23 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -84,8 +84,8 @@ const reportResponse = await getReport({ path: { id: testId } }); if (reportResponse.data) { report = reportResponse.data; + stopPolling(); } - stopPolling(); } } else if (testResponse.error) { handleApiError(testResponse.error, "Failed to fetch test"); From ea71074cc89ebe38d393f473dda9cda2b2e143d6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 11 Nov 2025 12:10:25 +0000 Subject: [PATCH 154/256] chore(deps): update dependency svelte-check to v4.3.4 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 01d6a6d..10c565e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3945,9 +3945,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", - "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz", + "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==", "dev": true, "license": "MIT", "dependencies": { From e28a96508d35eec77270335d9b2396334306929e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 14 Nov 2025 15:33:52 +0700 Subject: [PATCH 155/256] Respond with HTTP 200 on blacklist, domain and test pages Bug: https://github.com/happyDomain/happydeliver/issues/2 --- web/routes.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/routes.go b/web/routes.go index 44b1cb2..23a9bbb 100644 --- a/web/routes.go +++ b/web/routes.go @@ -86,6 +86,12 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/", serveOrReverse("/", cfg)) + router.GET("/blacklist/", serveOrReverse("/", cfg)) + router.GET("/blacklist/:ip", serveOrReverse("/", cfg)) + router.GET("/domain/", serveOrReverse("/", cfg)) + router.GET("/domain/:domain", serveOrReverse("/", cfg)) + router.GET("/test/", serveOrReverse("/", cfg)) + router.GET("/test/:testid", serveOrReverse("/", cfg)) router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/img/*path", serveOrReverse("", cfg)) From 04d8b150b4b84e5327334e826bcb12f1dcd28457 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 14 Nov 2025 09:11:24 +0000 Subject: [PATCH 156/256] chore(deps): update module golang.org/x/net to v0.47.0 --- go.mod | 17 ++++++++--------- go.sum | 34 ++++++++++++++-------------------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index db2ac1d..e20b404 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,16 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.46.0 + golang.org/x/net v0.47.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.0 ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -25,6 +23,7 @@ require ( github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -65,12 +64,12 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.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 266785d..1f557d7 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -98,7 +94,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -166,7 +161,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -195,11 +189,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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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= @@ -207,13 +201,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.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= @@ -229,21 +223,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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= From e05c6d0bc29714fc7508fa0d78d5d321f02646df Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 18:08:06 +0700 Subject: [PATCH 157/256] Fix calculateTextPlainConsistency algorithm --- pkg/analyzer/content.go | 45 ++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 4a3b5b8..95e32aa 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -659,30 +659,47 @@ func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText stri return 0.0 } - // Count common words - commonWords := 0 - plainWordSet := make(map[string]bool) + // Count common words by building sets + plainWordSet := make(map[string]int) for _, word := range plainWords { - plainWordSet[word] = true + plainWordSet[word]++ } + htmlWordSet := make(map[string]int) for _, word := range htmlWords { - if plainWordSet[word] { - commonWords++ + htmlWordSet[word]++ + } + + // Count matches: for each unique word, count minimum occurrences in both texts + commonWords := 0 + for word, plainCount := range plainWordSet { + if htmlCount, exists := htmlWordSet[word]; exists { + // Count the minimum occurrences between both texts + if plainCount < htmlCount { + commonWords += plainCount + } else { + commonWords += htmlCount + } } } - // Calculate ratio (Jaccard similarity approximation) - maxWords := len(plainWords) - if len(htmlWords) > maxWords { - maxWords = len(htmlWords) - } - - if maxWords == 0 { + // Calculate ratio using total words from both texts (union approach) + // This provides a balanced measure: perfect match = 1.0, partial overlap = 0.3-0.8 + totalWords := len(plainWords) + len(htmlWords) + if totalWords == 0 { return 0.0 } - return float32(commonWords) / float32(maxWords) + // Divide by average word count for better scoring + avgWords := float32(totalWords) / 2.0 + ratio := float32(commonWords) / avgWords + + // Cap at 1.0 for perfect matches + if ratio > 1.0 { + ratio = 1.0 + } + + return ratio } // normalizeText normalizes text for comparison From ee9fa59dbc8bc4a28b472bc5daf21c87aa1d54ee Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 1 Nov 2025 13:11:17 +0000 Subject: [PATCH 158/256] Update eslint monorepo to v9.39.0 --- web/package-lock.json | 55 +++++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 10c565e..46186e5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -539,19 +539,6 @@ } } }, - "node_modules/@eslint/compat/node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", @@ -568,22 +555,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -631,9 +618,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", + "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", "dev": true, "license": "MIT", "engines": { @@ -654,13 +641,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -2338,9 +2325,9 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz", + "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", "peer": true, @@ -2348,11 +2335,11 @@ "@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/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.0", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", From 723bec622afd919bd0f5cbb35928aa629a6e3315 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 18:08:06 +0700 Subject: [PATCH 159/256] Fix calculateTextPlainConsistency algorithm From 27d5220687493ebab7ea0b9d1faf946d2d518352 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 1 Nov 2025 13:10:56 +0000 Subject: [PATCH 160/256] Update dependency globals to v16.5.0 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 46186e5..96ce1f6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2763,9 +2763,9 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { From a3ca8ffb4876dea771a5f85789921bfc667f4c2d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 18:08:06 +0700 Subject: [PATCH 161/256] Fix calculateTextPlainConsistency algorithm From 03b58b6f19093ef935c56483f08a7ca91388ce7a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 1 Nov 2025 17:10:46 +0000 Subject: [PATCH 162/256] Update module github.com/oapi-codegen/oapi-codegen/v2 to v2.5.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e20b404..5cef1e4 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/go.sum b/go.sum index 1f557d7..1def6c0 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= From c19f545df0fde0182e2724a1196709657371623b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 14 Nov 2025 09:10:35 +0000 Subject: [PATCH 163/256] chore(deps): update dependency typescript-eslint to v8.46.4 --- web/package-lock.json | 122 +++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 96ce1f6..129c2c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1350,17 +1350,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", - "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/type-utils": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1374,7 +1374,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.2", + "@typescript-eslint/parser": "^8.46.4", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1390,17 +1390,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", - "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4" }, "engines": { @@ -1416,14 +1416,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", - "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", "debug": "^4.3.4" }, "engines": { @@ -1438,14 +1438,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", - "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1456,9 +1456,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", - "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", "dev": true, "license": "MIT", "engines": { @@ -1473,15 +1473,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", - "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1498,9 +1498,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", - "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true, "license": "MIT", "engines": { @@ -1512,16 +1512,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", - "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1567,16 +1567,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", - "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1591,13 +1591,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", - "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/types": "8.46.4", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4111,16 +4111,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz", - "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", + "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.2", - "@typescript-eslint/parser": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2" + "@typescript-eslint/eslint-plugin": "8.46.4", + "@typescript-eslint/parser": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From e194fcc5b1a3e8ad60e64033b4d5113e9fded57d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 1 Nov 2025 18:08:06 +0700 Subject: [PATCH 164/256] Fix calculateTextPlainConsistency algorithm From a1e8dd35bd4dcc83e44f1a61bfea4f92237d1041 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 1 Nov 2025 13:10:36 +0000 Subject: [PATCH 165/256] Update dependency @types/node to v24.9.2 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 129c2c5..2579fe0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1339,9 +1339,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", "peer": true, From 5ac0e2a8bf9280ab39c361db1f4e5a3407417751 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 14 Nov 2025 09:11:07 +0000 Subject: [PATCH 166/256] chore(deps): update dependency vite to v7.2.2 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 2579fe0..769b905 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4173,9 +4173,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", - "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", "peer": true, From 3bcbb5814d9f372e1c01a2a7b29b780def54bb48 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 14 Nov 2025 13:12:20 +0000 Subject: [PATCH 167/256] chore(deps): lock file maintenance --- web/package-lock.json | 563 +++++++++++++++++++++++------------------- 1 file changed, 303 insertions(+), 260 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 769b905..4ac32c2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -35,9 +35,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -52,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -69,9 +69,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -86,9 +86,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -103,9 +103,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -120,9 +120,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -154,9 +154,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -171,9 +171,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -188,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -205,9 +205,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -222,9 +222,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -239,9 +239,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -256,9 +256,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -273,9 +273,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -290,9 +290,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -324,9 +324,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -341,9 +341,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -358,9 +358,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -375,9 +375,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -392,9 +392,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -409,9 +409,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -426,9 +426,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -443,9 +443,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -460,9 +460,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -618,9 +618,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", - "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, "license": "MIT", "engines": { @@ -655,9 +655,9 @@ } }, "node_modules/@hey-api/codegen-core": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.2.tgz", - "integrity": "sha512-DhfftvmoJyfMiiNHhfU7xrDxrjMjPKex1g064RfE6HjNEsFYwK36J2yKfkn8I1mrYWHPmS5ZV3GarMZajsYEEQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz", + "integrity": "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg==", "dev": true, "license": "MIT", "engines": { @@ -885,9 +885,9 @@ } }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", "cpu": [ "arm" ], @@ -899,9 +899,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", "cpu": [ "arm64" ], @@ -913,9 +913,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", "cpu": [ "arm64" ], @@ -927,9 +927,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", "cpu": [ "x64" ], @@ -941,9 +941,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", "cpu": [ "arm64" ], @@ -955,9 +955,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", "cpu": [ "x64" ], @@ -969,9 +969,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", "cpu": [ "arm" ], @@ -983,9 +983,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", "cpu": [ "arm" ], @@ -997,9 +997,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", "cpu": [ "arm64" ], @@ -1011,9 +1011,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", "cpu": [ "arm64" ], @@ -1025,9 +1025,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", "cpu": [ "loong64" ], @@ -1039,9 +1039,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", "cpu": [ "ppc64" ], @@ -1053,9 +1053,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", "cpu": [ "riscv64" ], @@ -1067,9 +1067,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", "cpu": [ "riscv64" ], @@ -1081,9 +1081,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", "cpu": [ "s390x" ], @@ -1095,9 +1095,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", "cpu": [ "x64" ], @@ -1109,9 +1109,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", "cpu": [ "x64" ], @@ -1123,9 +1123,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", "cpu": [ "arm64" ], @@ -1137,9 +1137,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", "cpu": [ "arm64" ], @@ -1151,9 +1151,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", "cpu": [ "ia32" ], @@ -1165,9 +1165,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", "cpu": [ "x64" ], @@ -1179,9 +1179,9 @@ ] }, "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==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", "cpu": [ "x64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.48.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.0.tgz", - "integrity": "sha512-GAAbkWrbRJvysL7+HOWs5v/+TmnRcEQPeED2sUcDFTHpPvRYADEtScL6x8hWuKp0DKauJVaVJLTjQVy9e7cMiw==", + "version": "2.48.5", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz", + "integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==", "dev": true, "license": "MIT", "peer": true, @@ -1339,9 +1339,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", - "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "peer": true, @@ -2186,9 +2186,9 @@ } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.3.0.tgz", + "integrity": "sha512-Qq68+VkJlc8tjnPV1i7HtbIn7ohmjZa88qUvHMIK0ZKUXMCuV45cT7cEXALPUmeXCe0q1DWQkQTemHVaLIFSrg==", "dev": true, "license": "MIT", "dependencies": { @@ -2203,9 +2203,9 @@ } }, "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -2243,9 +2243,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz", - "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", + "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", "dev": true, "license": "MIT" }, @@ -2270,9 +2270,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2283,32 +2283,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -2325,9 +2325,9 @@ } }, "node_modules/eslint": { - "version": "9.39.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz", - "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", "peer": true, @@ -2338,7 +2338,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.0", + "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -2504,9 +2504,9 @@ } }, "node_modules/esrap": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz", - "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", + "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "dev": true, "license": "MIT", "dependencies": { @@ -2567,9 +2567,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2970,9 +2970,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3132,19 +3132,6 @@ "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", @@ -3396,14 +3383,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3675,9 +3661,9 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", "dev": true, "license": "MIT", "dependencies": { @@ -3691,28 +3677,28 @@ "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", + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", "fsevents": "~2.3.2" } }, @@ -3780,9 +3766,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "dev": true, "license": "MIT" }, @@ -3905,9 +3891,9 @@ } }, "node_modules/svelte": { - "version": "5.42.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz", - "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==", + "version": "5.43.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.6.tgz", + "integrity": "sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA==", "dev": true, "license": "MIT", "peer": true, @@ -3993,11 +3979,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4016,6 +4005,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -4271,6 +4273,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -4364,6 +4379,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest/node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -4437,6 +4465,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From f2261adb54974a1655270c257511b29252f24ae7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 17 Nov 2025 10:15:11 +0700 Subject: [PATCH 168/256] Update go dependancies --- go.mod | 38 +++++++++++++------------- go.sum | 84 ++++++++++++++++++++++++++++++++++------------------------ 2 files changed, 69 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 5cef1e4..ebf21a7 100644 --- a/go.mod +++ b/go.mod @@ -5,31 +5,33 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 golang.org/x/net v0.47.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.31.0 + gorm.io/gorm v1.31.1 ) require ( - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.2 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -42,7 +44,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -54,23 +56,23 @@ 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.1 // indirect - github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/quic-go/quic-go v0.56.0 // indirect + github.com/redis/go-redis/v9 v9.16.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - github.com/woodsbury/decimal128 v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.44.0 // indirect - golang.org/x/mod v0.29.0 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.10 // 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 1def6c0..cd951e8 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,19 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -30,26 +36,28 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg= +github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -94,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -105,8 +114,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= @@ -149,10 +158,10 @@ 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.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= -github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= +github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -161,39 +170,42 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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= @@ -233,11 +245,13 @@ 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 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= @@ -250,8 +264,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -273,5 +287,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= -gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= From eef6480e75936b7bce5601859e6f4acff8ec12e5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 17 Nov 2025 10:15:40 +0700 Subject: [PATCH 169/256] Refactor DNS resolution: create an interface to have multiple implementations --- pkg/analyzer/dns.go | 18 +++++--- pkg/analyzer/dns_resolver.go | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 pkg/analyzer/dns_resolver.go diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 57226c6..3098934 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -22,7 +22,6 @@ package analyzer import ( - "net" "time" "git.happydns.org/happyDeliver/internal/api" @@ -31,19 +30,26 @@ import ( // DNSAnalyzer analyzes DNS records for email domains type DNSAnalyzer struct { Timeout time.Duration - resolver *net.Resolver + resolver DNSResolver } // NewDNSAnalyzer creates a new DNS analyzer with configurable timeout func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { + return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver()) +} + +// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver. +// If resolver is nil, a StandardDNSResolver will be used. +func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer { if timeout == 0 { timeout = 10 * time.Second // Default timeout } + if resolver == nil { + resolver = NewStandardDNSResolver() + } return &DNSAnalyzer{ - Timeout: timeout, - resolver: &net.Resolver{ - PreferGo: true, - }, + Timeout: timeout, + resolver: resolver, } } diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go new file mode 100644 index 0000000..f60484f --- /dev/null +++ b/pkg/analyzer/dns_resolver.go @@ -0,0 +1,80 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" +) + +// DNSResolver defines the interface for DNS resolution operations. +// This interface abstracts DNS lookups to allow for custom implementations, +// such as mock resolvers for testing or caching resolvers for performance. +type DNSResolver interface { + // LookupMX returns the DNS MX records for the given domain. + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + + // LookupTXT returns the DNS TXT records for the given domain. + LookupTXT(ctx context.Context, name string) ([]string, error) + + // LookupAddr performs a reverse lookup for the given IP address, + // returning a list of hostnames mapping to that address. + LookupAddr(ctx context.Context, addr string) ([]string, error) + + // LookupHost looks up the given hostname using the local resolver. + // It returns a slice of that host's addresses (IPv4 and IPv6). + LookupHost(ctx context.Context, host string) ([]string, error) +} + +// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver. +type StandardDNSResolver struct { + resolver *net.Resolver +} + +// NewStandardDNSResolver creates a new StandardDNSResolver with default settings. +func NewStandardDNSResolver() DNSResolver { + return &StandardDNSResolver{ + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// LookupMX implements DNSResolver.LookupMX using net.Resolver. +func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + return r.resolver.LookupMX(ctx, name) +} + +// LookupTXT implements DNSResolver.LookupTXT using net.Resolver. +func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + return r.resolver.LookupTXT(ctx, name) +} + +// LookupAddr implements DNSResolver.LookupAddr using net.Resolver. +func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + return r.resolver.LookupAddr(ctx, addr) +} + +// LookupHost implements DNSResolver.LookupHost using net.Resolver. +func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + return r.resolver.LookupHost(ctx, host) +} From d81ff1731c840c175bb115ef1b5afcfef1c8959d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 17 Nov 2025 10:28:32 +0700 Subject: [PATCH 170/256] Fix tests --- pkg/analyzer/content_test.go | 6 +++--- pkg/analyzer/headers_test.go | 4 ++-- pkg/analyzer/parser_test.go | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index 0aa7ff9..9289d95 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -76,17 +76,17 @@ func TestExtractTextFromHTML(t *testing.T) { { name: "Multiple elements", html: "

    Title

    Paragraph

    ", - expectedText: "TitleParagraph", + expectedText: "Title Paragraph", }, { name: "With script tag", html: "

    Text

    More

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

    Text

    More

    ", - expectedText: "TextMore", + expectedText: "Text More", }, { name: "Empty HTML", diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 6a35d18..2513e6f 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -83,8 +83,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 40, - maxScore: 80, + minScore: 80, + maxScore: 90, }, { name: "Invalid Message-ID format", diff --git a/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go index 571f542..eb1fc6a 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8 } func TestGetAuthenticationResults(t *testing.T) { + // Force hostname + hostname = "example.com" + rawEmail := `From: sender@example.com To: recipient@example.com Subject: Test Email From e23afcc77cc5ffb1c4c930ec446ec175123e0986 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Nov 2025 14:37:39 +0700 Subject: [PATCH 171/256] Add container options to use certificates in postfix --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- docker/entrypoint.sh | 9 +++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a8f79e3..1f330c4 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,48 @@ docker run -d \ happydeliver:latest ``` -#### 3. Configure Network and DNS +#### 3. Configure TLS Certificates (Optional but Recommended) + +To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments. + +##### Using docker-compose + +Add the certificate paths to your `docker-compose.yml`: + +```yaml +environment: + - POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt + - POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key +volumes: + - /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro + - /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro +``` + +##### Using docker run + +```bash +docker run -d \ + --name happydeliver \ + -p 25:25 \ + -p 8080:8080 \ + -e HAPPYDELIVER_DOMAIN=yourdomain.com \ + -e HOSTNAME=mail.yourdomain.com \ + -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \ + -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \ + -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \ + -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \ + -v $(pwd)/data:/var/lib/happydeliver \ + -v $(pwd)/logs:/var/log/happydeliver \ + happydeliver:latest +``` + +**Notes:** +- The certificate file should contain the full certificate chain (certificate + intermediate CAs) +- The private key file must be readable by the postfix user inside the container +- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required) +- If both environment variables are not set, Postfix will run without TLS support + +#### 4. Configure Network and DNS ##### Open SMTP Port diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 99744f6..bfe6088 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,6 +25,15 @@ echo "Configuring Postfix..." sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf +# Add certificates to postfix +[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && { + cat <> /etc/postfix/main.cf +smtpd_tls_cert_file = ${POSTFIX_CERT_FILE} +smtpd_tls_key_file = ${POSTFIX_KEY_FILE} +smtpd_tls_security_level = may +EOF +} + # Replace placeholders in configurations sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json From 3e766924482a5104fd98c7870c93cab3dc8df230 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 17 Nov 2025 00:11:15 +0000 Subject: [PATCH 172/256] chore(deps): lock file maintenance --- web/package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 4ac32c2..91a57e4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1200,9 +1200,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", + "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2186,9 +2186,9 @@ } }, "node_modules/default-browser": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.3.0.tgz", - "integrity": "sha512-Qq68+VkJlc8tjnPV1i7HtbIn7ohmjZa88qUvHMIK0ZKUXMCuV45cT7cEXALPUmeXCe0q1DWQkQTemHVaLIFSrg==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", "dev": true, "license": "MIT", "dependencies": { @@ -2504,9 +2504,9 @@ } }, "node_modules/esrap": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", - "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", + "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", "dev": true, "license": "MIT", "dependencies": { @@ -3891,9 +3891,9 @@ } }, "node_modules/svelte": { - "version": "5.43.6", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.6.tgz", - "integrity": "sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA==", + "version": "5.43.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.8.tgz", + "integrity": "sha512-d53/xClCjHsuFXuHsn7+F/0NKkkwgRv8kLg2his5YBYqVtfIrBqkvWd+5ZjYN6ryk/jv/rJF00vexXHkK8ofXA==", "dev": true, "license": "MIT", "peer": true, From 016ed7180eaa3203065609202c6dc87cf2a5ab3c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 23 Nov 2025 19:41:31 +0700 Subject: [PATCH 173/256] Simplify docker usage, HOSTNAME variable is taken from container hostname Bug: https://github.com/happyDomain/happydeliver/issues/3 --- README.md | 6 +++--- docker-compose.yml | 6 +++--- docker/README.md | 7 ++++--- docker/entrypoint.sh | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1f330c4..3b28292 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git cd happydeliver # Edit docker-compose.yml to set your domain -# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables +# Change HAPPYDELIVER_DOMAIN environment variable and hostname # Build and start docker-compose up -d @@ -63,7 +63,7 @@ docker run -d \ -p 25:25 \ -p 8080:8080 \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - -e HOSTNAME=mail.yourdomain.com \ + --hostname mail.yourdomain.com \ -v $(pwd)/data:/var/lib/happydeliver \ -v $(pwd)/logs:/var/log/happydeliver \ happydeliver:latest @@ -94,9 +94,9 @@ docker run -d \ -p 25:25 \ -p 8080:8080 \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - -e HOSTNAME=mail.yourdomain.com \ -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \ -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \ + --hostname mail.yourdomain.com \ -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \ -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \ -v $(pwd)/data:/var/lib/happydeliver \ diff --git a/docker-compose.yml b/docker-compose.yml index fa27c5c..ccfd313 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,12 @@ services: dockerfile: Dockerfile image: happydomain/happydeliver:latest container_name: happydeliver + # Set a hostname hostname: mail.happydeliver.local environment: - # Set your domain and hostname - DOMAIN: happydeliver.local - HOSTNAME: mail.happydeliver.local + # Set your domain + HAPPYDELIVER_DOMAIN: happydeliver.local ports: # SMTP port diff --git a/docker/README.md b/docker/README.md index 45cce6b..3769365 100644 --- a/docker/README.md +++ b/docker/README.md @@ -109,12 +109,13 @@ Default configuration for the Docker environment: The container accepts these environment variables: -- `DOMAIN`: Email domain for test addresses (default: happydeliver.local) -- `HOSTNAME`: Container hostname (default: mail.happydeliver.local) +- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) + +Note that the hostname of the container is used to filter the authentication tests results. Example: ```bash -docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ... +docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... ``` ## Volumes diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bfe6088..1bc3eff 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,7 +4,7 @@ set -e echo "Starting happyDeliver container..." # Get environment variables with defaults -HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" +[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" From ca2ac3df7c4dd608c9b577add6af4fb6be778c20 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 28 Nov 2025 21:15:01 +0000 Subject: [PATCH 174/256] chore(deps): update dependency prettier to v3.7.2 --- web/package-lock.json | 82 +++++++++++-------------------------------- 1 file changed, 21 insertions(+), 61 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 91a57e4..55170c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3132,6 +3132,19 @@ "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", @@ -3383,13 +3396,14 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3556,9 +3570,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.2.tgz", + "integrity": "sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==", "dev": true, "license": "MIT", "peer": true, @@ -4005,19 +4019,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -4273,19 +4274,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -4379,19 +4367,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -4465,21 +4440,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 954cbe29fca46bb9bf75096cd9254dee7c8de0a5 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 25 Nov 2025 02:19:42 +0000 Subject: [PATCH 175/256] chore(deps): update module golang.org/x/crypto to v0.45.0 [security] --- go.mod | 5 ++--- go.sum | 10 ++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index ebf21a7..1a62c95 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 @@ -16,7 +15,6 @@ require ( ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -26,6 +24,7 @@ require ( 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.11 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.2 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect @@ -66,7 +65,7 @@ require ( github.com/woodsbury/decimal128 v1.4.0 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.44.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index cd951e8..f79c10e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -102,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -170,7 +165,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -201,8 +195,8 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= From 5701070cc1b1e1918927e4bfd8b0d86e092f4b9b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Dec 2025 07:14:52 +0000 Subject: [PATCH 176/256] chore(deps): update dependency vite to v7.2.6 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 55170c9..bd96852 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4176,9 +4176,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", "peer": true, From 5d02070100e841f2af9c0f0ab1f0916f779ab285 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 29 Nov 2025 19:14:29 +0000 Subject: [PATCH 177/256] chore(deps): update dependency prettier to v3.7.3 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index bd96852..1a35357 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3570,9 +3570,9 @@ } }, "node_modules/prettier": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.2.tgz", - "integrity": "sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", + "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", "peer": true, From 926796b79e33fe2157909ad04ba531dbf6a76444 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 2 Dec 2025 05:15:50 +0000 Subject: [PATCH 178/256] chore(deps): lock file maintenance --- web/package-lock.json | 586 ++++++++++++++---------------------------- 1 file changed, 192 insertions(+), 394 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 1a35357..5634377 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -581,9 +581,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -593,7 +593,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -828,44 +828,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -885,9 +847,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "cpu": [ "arm" ], @@ -899,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "cpu": [ "arm64" ], @@ -913,9 +875,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -927,9 +889,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -941,9 +903,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "cpu": [ "arm64" ], @@ -955,9 +917,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -969,9 +931,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "cpu": [ "arm" ], @@ -983,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "cpu": [ "arm" ], @@ -997,9 +959,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "cpu": [ "arm64" ], @@ -1011,9 +973,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "cpu": [ "arm64" ], @@ -1025,9 +987,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "cpu": [ "loong64" ], @@ -1039,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "cpu": [ "ppc64" ], @@ -1053,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "cpu": [ "riscv64" ], @@ -1067,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "cpu": [ "riscv64" ], @@ -1081,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "cpu": [ "s390x" ], @@ -1095,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "cpu": [ "x64" ], @@ -1109,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "cpu": [ "x64" ], @@ -1123,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "cpu": [ "arm64" ], @@ -1137,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "cpu": [ "arm64" ], @@ -1151,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "cpu": [ "ia32" ], @@ -1165,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "cpu": [ "x64" ], @@ -1179,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "cpu": [ "x64" ], @@ -1200,9 +1162,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", - "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1220,9 +1182,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.48.5", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz", - "integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==", + "version": "2.49.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz", + "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==", "dev": true, "license": "MIT", "peer": true, @@ -1350,17 +1312,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1374,7 +1336,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1390,17 +1352,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1416,14 +1378,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1438,14 +1400,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1456,9 +1418,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", "dev": true, "license": "MIT", "engines": { @@ -1473,15 +1435,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1498,9 +1460,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -1512,21 +1474,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1567,16 +1528,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1591,13 +1552,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1880,19 +1841,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2504,9 +2452,9 @@ } }, "node_modules/esrap": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", - "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2580,36 +2528,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2624,16 +2542,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2665,19 +2573,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2909,16 +2804,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -3108,43 +2993,6 @@ "@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", @@ -3546,9 +3394,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -3607,27 +3455,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -3663,21 +3490,10 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3691,28 +3507,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" } }, @@ -3729,30 +3545,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3905,9 +3697,9 @@ } }, "node_modules/svelte": { - "version": "5.43.8", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.8.tgz", - "integrity": "sha512-d53/xClCjHsuFXuHsn7+F/0NKkkwgRv8kLg2his5YBYqVtfIrBqkvWd+5ZjYN6ryk/jv/rJF00vexXHkK8ofXA==", + "version": "5.45.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz", + "integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==", "dev": true, "license": "MIT", "peer": true, @@ -3920,8 +3712,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4049,19 +3842,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4114,16 +3894,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4440,6 +4220,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 528a65ca0483a4c445a8a0296ff93f6078194d7b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 8 Dec 2025 01:15:59 +0000 Subject: [PATCH 179/256] chore(deps): lock file maintenance --- web/package-lock.json | 156 +++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5634377..e7bf363 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1182,9 +1182,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.49.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz", - "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==", + "version": "2.49.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.1.tgz", + "integrity": "sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==", "dev": true, "license": "MIT", "peer": true, @@ -1312,17 +1312,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1336,7 +1336,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", + "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1352,17 +1352,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "engines": { @@ -1378,14 +1378,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "engines": { @@ -1400,14 +1400,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1418,9 +1418,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, "license": "MIT", "engines": { @@ -1435,15 +1435,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1460,9 +1460,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, "license": "MIT", "engines": { @@ -1474,16 +1474,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -1528,16 +1528,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1552,13 +1552,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2350,9 +2350,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.13.0.tgz", - "integrity": "sha512-2ohCCQJJTNbIpQCSDSTWj+FN0OVfPmSO03lmSNT7ytqMaWF6kpT86LdzDqtm4sh7TVPl/OEWJ/d7R87bXP2Vjg==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.13.1.tgz", + "integrity": "sha512-Ng+kV/qGS8P/isbNYVE3sJORtubB+yLEcYICMkUWNaDTb0SwZni/JhAYXh/Dz/q2eThUwWY0VMPZ//KYD1n3eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3418,9 +3418,9 @@ } }, "node_modules/prettier": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", - "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "peer": true, @@ -3697,9 +3697,9 @@ } }, "node_modules/svelte": { - "version": "5.45.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz", - "integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==", + "version": "5.45.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz", + "integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==", "dev": true, "license": "MIT", "peer": true, @@ -3714,7 +3714,7 @@ "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.2.0", + "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3749,9 +3749,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", - "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", + "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", "dev": true, "license": "MIT", "dependencies": { @@ -3764,7 +3764,7 @@ }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.18.3" + "pnpm": "10.24.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -3894,16 +3894,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", - "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", + "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0" + "@typescript-eslint/eslint-plugin": "8.48.1", + "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From 6081e486bfa757c83e23c54baec3b669779fb316 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 20 Dec 2025 07:15:11 +0000 Subject: [PATCH 180/256] chore(deps): update dependency svelte-check to v4.3.5 --- web/package-lock.json | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index e7bf363..8791088 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3725,9 +3725,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz", - "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4220,24 +4220,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 11d46de033bf2f1b3a8293e3eb19464395ad2645 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 14 Dec 2025 21:15:06 +0000 Subject: [PATCH 181/256] chore(deps): update dependency prettier-plugin-svelte to v3.4.1 --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 8791088..4ed6e7b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3435,9 +3435,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", + "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", "dev": true, "license": "MIT", "peerDependencies": { From 57a3774d2845216bf7b2c29dbe578196c132977e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 8 Dec 2025 23:16:01 +0000 Subject: [PATCH 182/256] chore(deps): update module golang.org/x/net to v0.48.0 --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 1a62c95..e035147 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( 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.47.0 + golang.org/x/net v0.48.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -65,11 +65,11 @@ require ( github.com/woodsbury/decimal128 v1.4.0 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index f79c10e..0d141fe 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,8 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= @@ -207,13 +207,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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -229,16 +229,16 @@ 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 0fda0f88c1646df0a52dcb527c745d875db5ce39 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 2 Dec 2025 07:15:00 +0000 Subject: [PATCH 183/256] chore(deps): update dependency @eslint/compat to v2 --- web/package-lock.json | 25 +++++++++++++++++++------ web/package.json | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 4ed6e7b..e020b39 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,7 @@ "bootstrap-icons": "^1.13.1" }, "devDependencies": { - "@eslint/compat": "^1.4.0", + "@eslint/compat": "^2.0.0", "@eslint/js": "^9.36.0", "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", @@ -519,16 +519,16 @@ } }, "node_modules/@eslint/compat": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", - "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.0.tgz", + "integrity": "sha512-T9AfE1G1uv4wwq94ozgTGio5EUQBqAVe1X9qsQtSNVEYW6j3hvtZVm8Smr4qL1qDPFg+lOB2cL5RxTRMzq4CTA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { "eslint": "^8.40 || 9" @@ -539,6 +539,19 @@ } } }, + "node_modules/@eslint/compat/node_modules/@eslint/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.0.0.tgz", + "integrity": "sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", diff --git a/web/package.json b/web/package.json index c1efabe..8ba17ca 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,7 @@ "generate:api": "openapi-ts" }, "devDependencies": { - "@eslint/compat": "^1.4.0", + "@eslint/compat": "^2.0.0", "@eslint/js": "^9.36.0", "@hey-api/openapi-ts": "0.86.10", "@sveltejs/adapter-static": "^3.0.9", From 1ba35c6f9f315fb0ace535f2273f161b48eaf8b0 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 1 Jan 2026 19:16:13 +0000 Subject: [PATCH 184/256] chore(deps): update dependency globals to v17 --- web/package-lock.json | 21 +++++++++++++++++---- web/package.json | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index e020b39..675e31a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", @@ -2396,6 +2396,19 @@ } } }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -2671,9 +2684,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", "dev": true, "license": "MIT", "engines": { diff --git a/web/package.json b/web/package.json index 8ba17ca..e5b88f2 100644 --- a/web/package.json +++ b/web/package.json @@ -26,7 +26,7 @@ "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", + "globals": "^17.0.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.5", From dc21b72f52f3a1b1fdb77fa3754d586ce38d6c83 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 29 Dec 2025 03:52:30 +0000 Subject: [PATCH 185/256] chore(deps): lock file maintenance --- web/package-lock.json | 662 ++++++++++++++++++++++-------------------- 1 file changed, 349 insertions(+), 313 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 675e31a..2edd780 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -35,9 +35,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -52,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -69,9 +69,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -86,9 +86,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -103,9 +103,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -120,9 +120,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -154,9 +154,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -171,9 +171,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -188,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -205,9 +205,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -222,9 +222,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -239,9 +239,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -256,9 +256,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -273,9 +273,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -290,9 +290,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -324,9 +324,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -341,9 +341,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -358,9 +358,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -375,9 +375,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -392,9 +392,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -409,9 +409,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -426,9 +426,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -443,9 +443,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -460,9 +460,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -539,19 +539,6 @@ } } }, - "node_modules/@eslint/compat/node_modules/@eslint/core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.0.0.tgz", - "integrity": "sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", @@ -580,7 +567,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", @@ -593,6 +580,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.0.0.tgz", + "integrity": "sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", @@ -631,9 +631,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -667,6 +667,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@hey-api/codegen-core": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.3.tgz", @@ -860,9 +873,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "cpu": [ "arm" ], @@ -874,9 +887,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "cpu": [ "arm64" ], @@ -888,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "cpu": [ "arm64" ], @@ -902,9 +915,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "cpu": [ "x64" ], @@ -916,9 +929,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "cpu": [ "arm64" ], @@ -930,9 +943,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "cpu": [ "x64" ], @@ -944,9 +957,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "cpu": [ "arm" ], @@ -958,9 +971,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "cpu": [ "arm" ], @@ -972,9 +985,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "cpu": [ "arm64" ], @@ -986,9 +999,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "cpu": [ "arm64" ], @@ -1000,9 +1013,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "cpu": [ "loong64" ], @@ -1014,9 +1027,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "cpu": [ "ppc64" ], @@ -1028,9 +1041,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "cpu": [ "riscv64" ], @@ -1042,9 +1055,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "cpu": [ "riscv64" ], @@ -1056,9 +1069,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "cpu": [ "s390x" ], @@ -1070,9 +1083,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "cpu": [ "x64" ], @@ -1084,9 +1097,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "cpu": [ "x64" ], @@ -1098,9 +1111,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "cpu": [ "arm64" ], @@ -1112,9 +1125,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "cpu": [ "arm64" ], @@ -1126,9 +1139,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "cpu": [ "ia32" ], @@ -1140,9 +1153,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "cpu": [ "x64" ], @@ -1154,9 +1167,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "cpu": [ "x64" ], @@ -1168,9 +1181,9 @@ ] }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -1195,9 +1208,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.49.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.1.tgz", - "integrity": "sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==", + "version": "2.49.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz", + "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", "peer": true, @@ -1314,9 +1327,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", "peer": true, @@ -1325,18 +1338,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", + "integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/type-utils": "8.50.1", + "@typescript-eslint/utils": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1349,7 +1361,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1365,17 +1377,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", + "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "engines": { @@ -1391,14 +1403,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz", + "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.50.1", + "@typescript-eslint/types": "^8.50.1", "debug": "^4.3.4" }, "engines": { @@ -1413,14 +1425,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz", + "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1431,9 +1443,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz", + "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", "dev": true, "license": "MIT", "engines": { @@ -1448,15 +1460,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz", + "integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1473,9 +1485,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", + "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", "dev": true, "license": "MIT", "engines": { @@ -1487,16 +1499,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz", + "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.50.1", + "@typescript-eslint/tsconfig-utils": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -1541,16 +1553,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz", + "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1565,13 +1577,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz", + "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.50.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2204,9 +2216,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", - "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", + "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "dev": true, "license": "MIT" }, @@ -2231,9 +2243,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2244,32 +2256,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escape-string-regexp": { @@ -2286,9 +2298,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "peer": true, @@ -2299,7 +2311,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -2439,6 +2451,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2531,9 +2556,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2696,13 +2721,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -3517,9 +3535,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", "dependencies": { @@ -3533,28 +3551,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" } }, @@ -3723,9 +3741,9 @@ } }, "node_modules/svelte": { - "version": "5.45.6", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz", - "integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dev": true, "license": "MIT", "peer": true, @@ -3879,9 +3897,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", + "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", "dev": true, "license": "MIT", "engines": { @@ -3920,16 +3938,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz", + "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3982,14 +4000,14 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4246,6 +4264,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 9ac3e165fa01d7fa66f0492af30c40275c92098e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 3 Jan 2026 12:18:07 +0700 Subject: [PATCH 186/256] Readd missing go dep --- go.mod | 3 ++- go.sum | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e035147..3cdc587 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 @@ -15,6 +16,7 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -24,7 +26,6 @@ require ( 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.11 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.2 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect diff --git a/go.sum b/go.sum index 0d141fe..9c5081a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -98,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -165,6 +170,7 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From d1e48b9885e7b850c714ec496007f49ea2f27be6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 5 Jan 2026 01:15:53 +0000 Subject: [PATCH 187/256] chore(deps): lock file maintenance --- web/package-lock.json | 152 +++++++++++++++++++++--------------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 2edd780..6f88380 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -477,9 +477,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1338,20 +1338,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", - "integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.50.1", - "@typescript-eslint/type-utils": "8.50.1", - "@typescript-eslint/utils": "8.50.1", - "@typescript-eslint/visitor-keys": "8.50.1", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1361,7 +1361,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.50.1", + "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1377,17 +1377,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", - "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.50.1", - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/typescript-estree": "8.50.1", - "@typescript-eslint/visitor-keys": "8.50.1", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "engines": { @@ -1403,14 +1403,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz", - "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.50.1", - "@typescript-eslint/types": "^8.50.1", + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "engines": { @@ -1425,14 +1425,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz", - "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/visitor-keys": "8.50.1" + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1443,9 +1443,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz", - "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", "dev": true, "license": "MIT", "engines": { @@ -1460,17 +1460,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz", - "integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/typescript-estree": "8.50.1", - "@typescript-eslint/utils": "8.50.1", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1485,9 +1485,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", - "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", "dev": true, "license": "MIT", "engines": { @@ -1499,21 +1499,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz", - "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.50.1", - "@typescript-eslint/tsconfig-utils": "8.50.1", - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/visitor-keys": "8.50.1", + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1553,16 +1553,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz", - "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.50.1", - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/typescript-estree": "8.50.1" + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1577,13 +1577,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz", - "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1966,9 +1966,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2490,9 +2490,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3897,9 +3897,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", - "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -3938,16 +3938,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz", - "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", + "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.50.1", - "@typescript-eslint/parser": "8.50.1", - "@typescript-eslint/typescript-estree": "8.50.1", - "@typescript-eslint/utils": "8.50.1" + "@typescript-eslint/eslint-plugin": "8.51.0", + "@typescript-eslint/parser": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From e6746a1382a1905d6bd6d9d38e5d9215a92eb997 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 12 Jan 2026 17:13:54 +0000 Subject: [PATCH 188/256] chore(deps): update module golang.org/x/net to v0.49.0 --- go.mod | 15 +++++++-------- go.sum | 30 ++++++++++++------------------ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 3cdc587..04c9d76 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,16 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/net v0.48.0 + golang.org/x/net v0.49.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -26,6 +24,7 @@ require ( 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.11 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.2 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect @@ -66,12 +65,12 @@ require ( github.com/woodsbury/decimal128 v1.4.0 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/mod v0.30.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect google.golang.org/protobuf v1.36.10 // 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 9c5081a..a2fbfe7 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -102,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -170,7 +165,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -201,11 +195,11 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 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= @@ -213,8 +207,8 @@ 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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= @@ -235,23 +229,23 @@ 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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= From a6efd7710e09a4c5ed27ff1a0646def81ab3c2fd Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 3 Jan 2026 07:14:34 +0000 Subject: [PATCH 189/256] chore(deps): update module github.com/quic-go/quic-go to v0.57.0 [security] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 04c9d76..14f4c0d 100644 --- a/go.mod +++ b/go.mod @@ -54,8 +54,8 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.56.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.0 // indirect github.com/redis/go-redis/v9 v9.16.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect diff --git a/go.sum b/go.sum index a2fbfe7..e17672d 100644 --- a/go.sum +++ b/go.sum @@ -151,10 +151,10 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= -github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= +github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= From 035e864de46e24dbd23592b457909e8c7757319b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 24 Jan 2026 18:19:06 +0800 Subject: [PATCH 190/256] Update go modules --- go.mod | 27 ++++++++++++++------------- go.sum | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 14f4c0d..e9da3d6 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.6 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 @@ -15,27 +16,27 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.14.2 // indirect - github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/gabriel-vasile/mimetype v1.4.11 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.22.2 // indirect - github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -45,7 +46,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -55,8 +56,8 @@ 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.6.0 // indirect - github.com/quic-go/quic-go v0.57.0 // indirect - github.com/redis/go-redis/v9 v9.16.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -71,7 +72,7 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e17672d..96ea7bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -8,8 +12,12 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -34,6 +42,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -42,8 +52,12 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg= github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -54,6 +68,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -61,6 +77,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -88,6 +106,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -98,6 +118,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -115,6 +136,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -155,8 +178,12 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -165,6 +192,7 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -239,6 +267,7 @@ 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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -260,6 +289,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From ac9b567025d3cad6c5449b66b7c270c844433d5f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 24 Jan 2026 19:18:26 +0800 Subject: [PATCH 191/256] web: Format code files --- .../lib/components/AuthenticationCard.svelte | 649 +++++++++++------- web/src/lib/components/BlacklistCard.svelte | 24 +- .../lib/components/ContentAnalysisCard.svelte | 43 +- web/src/lib/components/DnsRecordsCard.svelte | 71 +- web/src/lib/components/EmailPathCard.svelte | 16 +- web/src/lib/components/GradeDisplay.svelte | 5 +- .../lib/components/HeaderAnalysisCard.svelte | 253 +++++-- .../lib/components/MxRecordsDisplay.svelte | 1 + web/src/lib/components/ScoreCard.svelte | 36 +- .../lib/components/SpamAssassinCard.svelte | 31 +- .../lib/components/SpfRecordsDisplay.svelte | 53 +- web/src/lib/components/SummaryCard.svelte | 30 +- web/src/lib/components/index.ts | 38 +- web/src/lib/stores/theme.ts | 23 +- web/src/routes/+error.svelte | 6 +- web/src/routes/+layout.svelte | 6 +- web/src/routes/+page.svelte | 3 +- web/src/routes/blacklist/+page.svelte | 27 +- web/src/routes/blacklist/[ip]/+page.svelte | 61 +- web/src/routes/domain/+page.svelte | 23 +- web/src/routes/domain/[domain]/+page.svelte | 21 +- web/src/routes/test/[test]/+page.svelte | 29 +- 22 files changed, 977 insertions(+), 472 deletions(-) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 8f22eac..097dff1 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -96,281 +96,442 @@
    - - {#if authentication.iprev} -
    -
    - -
    - IP Reverse DNS - - {authentication.iprev.result} - - {#if authentication.iprev.ip} -
    - IP Address: - {authentication.iprev.ip} -
    - {/if} - {#if authentication.iprev.hostname} -
    - Hostname: - {authentication.iprev.hostname} -
    - {/if} - {#if authentication.iprev.details} -
    {authentication.iprev.details}
    - {/if} -
    + + {#if authentication.iprev} +
    +
    + +
    + IP Reverse DNS + + {authentication.iprev.result} + + {#if authentication.iprev.ip} +
    + IP Address: + {authentication.iprev.ip} +
    + {/if} + {#if authentication.iprev.hostname} +
    + Hostname: + {authentication.iprev.hostname} +
    + {/if} + {#if authentication.iprev.details} +
    {authentication.iprev.details}
    + {/if}
    - {/if} - - -
    -
    - {#if authentication.spf} - -
    - SPF - - {authentication.spf.result} - - {#if authentication.spf.domain} -
    - Domain: - {authentication.spf.domain} -
    - {/if} - {#if authentication.spf.details} -
    {authentication.spf.details}
    - {/if} -
    - {:else} - -
    - SPF - - {getAuthResultText('missing')} - -
    SPF record is required for proper email authentication
    -
    - {/if} -
    + {/if} - -
    - {#if authentication.dkim && authentication.dkim.length > 0} - {#each authentication.dkim as dkim, i} -
    0}> - -
    - DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ''} - - {dkim.result} - - {#if dkim.domain} -
    - Domain: - {dkim.domain} -
    - {/if} - {#if dkim.selector} -
    - Selector: - {dkim.selector} -
    - {/if} - {#if dkim.details} -
    {dkim.details}
    - {/if} + +
    +
    + {#if authentication.spf} + +
    + SPF + + {authentication.spf.result} + + {#if authentication.spf.domain} +
    + Domain: + {authentication.spf.domain}
    -
    - {/each} + {/if} + {#if authentication.spf.details} +
    {authentication.spf.details}
    + {/if} +
    {:else} -
    - -
    - DKIM - - {getAuthResultText('missing')} - -
    DKIM signature is required for proper email authentication
    + +
    + SPF + + {getAuthResultText("missing")} + +
    + SPF record is required for proper email authentication
    {/if}
    +
    - - {#if authentication.x_google_dkim} -
    -
    - + +
    + {#if authentication.dkim && authentication.dkim.length > 0} + {#each authentication.dkim as dkim, i} +
    0}> +
    - X-Google-DKIM - - - {authentication.x_google_dkim.result} + DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""} + + {dkim.result} - {#if authentication.x_google_dkim.domain} + {#if dkim.domain}
    Domain: - {authentication.x_google_dkim.domain} + {dkim.domain}
    {/if} - {#if authentication.x_google_dkim.selector} + {#if dkim.selector}
    Selector: - {authentication.x_google_dkim.selector} + {dkim.selector}
    {/if} - {#if authentication.x_google_dkim.details} -
    {authentication.x_google_dkim.details}
    + {#if dkim.details} +
    {dkim.details}
    {/if}
    -
    - {/if} - - - {#if authentication.x_aligned_from} -
    -
    - -
    - X-Aligned-From - - - {authentication.x_aligned_from.result} - - {#if authentication.x_aligned_from.domain} -
    - Domain: - {authentication.x_aligned_from.domain} -
    - {/if} - {#if authentication.x_aligned_from.details} -
    {authentication.x_aligned_from.details}
    - {/if} -
    -
    -
    - {/if} - - -
    + {/each} + {:else}
    - {#if authentication.dmarc} - -
    - DMARC - - {authentication.dmarc.result} - - {#if authentication.dmarc.domain} -
    - Domain: - {authentication.dmarc.domain} -
    - {/if} - {#snippet DMARCPolicy(policy: string)} -
    - Policy: - - {policy} - -
    - {/snippet} - {#if authentication.dmarc.result != "none"} - {#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} - {@const policy = authentication.dmarc.details.replace(/^.*policy.published-domain-policy=([^\s]+).*$/, "$1")} - {@render DMARCPolicy(policy)} - {:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy} - {@render DMARCPolicy(dnsResults.dmarc_record.policy)} - {/if} - {/if} - {#if authentication.dmarc.details} -
    {authentication.dmarc.details}
    - {/if} + +
    + DKIM + + {getAuthResultText("missing")} + +
    + DKIM signature is required for proper email authentication
    - {:else} - -
    - DMARC - - {getAuthResultText('missing')} - -
    DMARC policy is required for proper email authentication
    -
    - {/if} +
    +
    + {/if} +
    + + + {#if authentication.x_google_dkim} +
    +
    + +
    + X-Google-DKIM + + + {authentication.x_google_dkim.result} + + {#if authentication.x_google_dkim.domain} +
    + Domain: + {authentication.x_google_dkim.domain} +
    + {/if} + {#if authentication.x_google_dkim.selector} +
    + Selector: + {authentication.x_google_dkim.selector} +
    + {/if} + {#if authentication.x_google_dkim.details} +
    {authentication.x_google_dkim
    +                                    .details}
    + {/if} +
    + {/if} - -
    + + {#if authentication.x_aligned_from} +
    - {#if authentication.bimi && authentication.bimi.result != "none"} - -
    - BIMI - - {authentication.bimi.result} - - {#if authentication.bimi.details} -
    {authentication.bimi.details}
    - {/if} -
    - {:else if authentication.bimi && authentication.bimi.result == "none"} - -
    - BIMI - - NONE - -
    Brand Indicators for Message Identification
    - {#if authentication.bimi.details} -
    {authentication.bimi.details}
    - {/if} -
    - {:else} - -
    - BIMI - - Optional - -
    Brand Indicators for Message Identification (optional enhancement)
    -
    - {/if} -
    -
    - - - {#if authentication.arc} -
    -
    - -
    - ARC - - {authentication.arc.result} - - {#if authentication.arc.chain_length} -
    Chain length: {authentication.arc.chain_length}
    - {/if} - {#if authentication.arc.details} -
    {authentication.arc.details}
    - {/if} -
    + +
    + X-Aligned-From + + + {authentication.x_aligned_from.result} + + {#if authentication.x_aligned_from.domain} +
    + Domain: + {authentication.x_aligned_from.domain} +
    + {/if} + {#if authentication.x_aligned_from.details} +
    {authentication.x_aligned_from
    +                                    .details}
    + {/if}
    - {/if} +
    + {/if} + + +
    +
    + {#if authentication.dmarc} + +
    + DMARC + + {authentication.dmarc.result} + + {#if authentication.dmarc.domain} +
    + Domain: + {authentication.dmarc.domain} +
    + {/if} + {#snippet DMARCPolicy(policy: string)} +
    + Policy: + + {policy} + +
    + {/snippet} + {#if authentication.dmarc.result != "none"} + {#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} + {@const policy = authentication.dmarc.details.replace( + /^.*policy.published-domain-policy=([^\s]+).*$/, + "$1", + )} + {@render DMARCPolicy(policy)} + {:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy} + {@render DMARCPolicy(dnsResults.dmarc_record.policy)} + {/if} + {/if} + {#if authentication.dmarc.details} +
    {authentication.dmarc.details}
    + {/if} +
    + {:else} + +
    + DMARC + + {getAuthResultText("missing")} + +
    + DMARC policy is required for proper email authentication +
    +
    + {/if} +
    +
    + + +
    +
    + {#if authentication.bimi && authentication.bimi.result != "none"} + +
    + BIMI + + {authentication.bimi.result} + + {#if authentication.bimi.details} +
    {authentication.bimi.details}
    + {/if} +
    + {:else if authentication.bimi && authentication.bimi.result == "none"} + +
    + BIMI + NONE +
    + Brand Indicators for Message Identification +
    + {#if authentication.bimi.details} +
    {authentication.bimi.details}
    + {/if} +
    + {:else} + +
    + BIMI + Optional +
    + Brand Indicators for Message Identification (optional enhancement) +
    +
    + {/if} +
    +
    + + + {#if authentication.arc} +
    +
    + +
    + ARC + + {authentication.arc.result} + + {#if authentication.arc.chain_length} +
    + Chain length: {authentication.arc.chain_length} +
    + {/if} + {#if authentication.arc.details} +
    {authentication.arc.details}
    + {/if} +
    +
    +
    + {/if}
    diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index bb0a160..7f9b7f2 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -2,8 +2,8 @@ import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen"; import { getScoreColorClass } from "$lib/score"; import { theme } from "$lib/stores/theme"; - import GradeDisplay from "./GradeDisplay.svelte"; import EmailPathCard from "./EmailPathCard.svelte"; + import GradeDisplay from "./GradeDisplay.svelte"; interface Props { blacklists: Record; @@ -16,11 +16,7 @@
    -
    +

    @@ -54,9 +50,19 @@

    {#each checks as check} - diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index 87cfd5e..51c4e5b 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -36,16 +36,28 @@
    - + HTML Part
    - + Plaintext Part
    - {#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'} + {#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
    - + Unsubscribe Link
    {/if} @@ -74,7 +86,14 @@
    Content Issues
    {#each contentAnalysis.html_issues as issue} -
    +
    {issue.type} @@ -118,11 +137,17 @@ {/if}
    - + {/each} @@ -146,11 +171,11 @@ {#each contentAnalysis.images as image} - +
    - - {check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')} + + + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Clean"} {check.rbl} - + {link.status} {link.http_code || '-'}{link.http_code || "-"}
    {image.src || '-'}{image.src || "-"} {#if image.has_alt} - {image.alt_text || 'Present'} + {image.alt_text || "Present"} {:else} Missing diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 337f7c1..b7997b0 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,15 +1,15 @@ - + {#if grade} {grade} {:else} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index e0ecb58..b26b492 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -1,5 +1,5 @@ - +
    diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index d3f17a3..7c23d10 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,8 +1,9 @@ - {report ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ''} ${report.test_id?.slice(0, 7) || ''}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver + + {report + ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}` + : test + ? `Test ${test.id.slice(0, 7)}` + : "Loading..."} - happyDeliver +
    From 6b4ca126b07a86d18d7f469d0af57eaceab06106 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 24 Jan 2026 21:23:40 +0800 Subject: [PATCH 192/256] Add colors to css --- web/src/app.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/app.css b/web/src/app.css index 1472994..dca80a5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,6 +1,9 @@ :root { --bs-primary: #1cb487; --bs-primary-rgb: 28, 180, 135; + --bs-link-color-rgb: 28, 180, 135; + --bs-link-hover-color-rgb: 17, 112, 84; + --bs-tertiary-bg: #e7e8e8; } body { @@ -8,6 +11,10 @@ body { -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } +.bg-tertiary { + background-color: var(--bs-tertiary-bg); +} + /* Animations */ @keyframes fadeIn { from { From 5453c0942057322e0cb674167ef3c4cd0b107116 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 24 Jan 2026 21:24:20 +0800 Subject: [PATCH 193/256] Use slimmer footer by default Bug: https://github.com/happyDomain/happydeliver/issues/6 --- web/src/routes/+layout.svelte | 43 +++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 652fb88..0d3fb23 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -55,7 +55,26 @@ {@render children?.()} -