Update dependency vitest to v4 #13
36 changed files with 1450 additions and 614 deletions
35
README.md
35
README.md
|
|
@ -13,6 +13,8 @@ An open-source email deliverability testing platform that analyzes test emails a
|
|||
- **Database Storage**: SQLite or PostgreSQL support
|
||||
- **Configurable**: via environment or config file for all settings
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker (Recommended)
|
||||
|
|
@ -67,6 +69,39 @@ docker run -d \
|
|||
happydeliver:latest
|
||||
```
|
||||
|
||||
#### 3. Configure Network and DNS
|
||||
|
||||
##### Open SMTP Port
|
||||
|
||||
Port 25 (SMTP) must be accessible from the internet to receive test emails:
|
||||
|
||||
```bash
|
||||
# Check if port 25 is listening
|
||||
netstat -ln | grep :25
|
||||
|
||||
# Allow port 25 through firewall (example with ufw)
|
||||
sudo ufw allow 25/tcp
|
||||
|
||||
# For iptables
|
||||
sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT
|
||||
```
|
||||
|
||||
**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support.
|
||||
|
||||
##### Configure DNS Records
|
||||
|
||||
Point your domain to the server's IP address.
|
||||
|
||||
```
|
||||
yourdomain.com. IN A 203.0.113.10
|
||||
yourdomain.com. IN AAAA 2001:db8::10
|
||||
```
|
||||
|
||||
Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly.
|
||||
|
||||
There is no need for an MX record here since the same host will serve both HTTP and SMTP.
|
||||
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### 1. Build
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -3,8 +3,9 @@ module git.happydns.org/happyDeliver
|
|||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/getkin/kin-openapi v0.132.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,7 +16,6 @@ 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
|
||||
|
|
@ -62,6 +62,7 @@ require (
|
|||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // 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
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -4,6 +4,10 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMz
|
|||
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=
|
||||
|
|
@ -32,8 +36,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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/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=
|
||||
|
|
@ -53,8 +57,6 @@ github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
|
|||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
|
|
@ -183,6 +185,8 @@ 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/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/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=
|
||||
|
|
|
|||
|
|
@ -86,18 +86,549 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
|
|||
|
||||
// outputHumanReadable outputs a human-readable summary
|
||||
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
|
||||
// Header
|
||||
report := result.Report
|
||||
|
||||
// Header with overall score
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
|
||||
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||
fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade)
|
||||
fmt.Fprintf(writer, "Test ID: %s\n", report.TestId)
|
||||
fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||
|
||||
// Detailed checks
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
// Score Summary
|
||||
if report.Summary != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "SCORE BREAKDOWN")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
// TODO
|
||||
summary := report.Summary
|
||||
fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n",
|
||||
summary.DnsScore, summary.DnsGrade)
|
||||
fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n",
|
||||
summary.AuthenticationScore, summary.AuthenticationGrade)
|
||||
fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n",
|
||||
summary.BlacklistScore, summary.BlacklistGrade)
|
||||
fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n",
|
||||
summary.HeaderScore, summary.HeaderGrade)
|
||||
fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n",
|
||||
summary.SpamScore, summary.SpamGrade)
|
||||
fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n",
|
||||
summary.ContentScore, summary.ContentGrade)
|
||||
}
|
||||
|
||||
// DNS Results
|
||||
if report.DnsResults != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DNS CONFIGURATION")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
dns := report.DnsResults
|
||||
fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain)
|
||||
if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain {
|
||||
fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain)
|
||||
}
|
||||
|
||||
// MX Records
|
||||
if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n MX Records (From Domain):")
|
||||
for _, mx := range *dns.FromMxRecords {
|
||||
status := "✓"
|
||||
if !mx.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
|
||||
if mx.Error != nil {
|
||||
fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// SPF Records
|
||||
if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n SPF Records:")
|
||||
for _, spf := range *dns.SpfRecords {
|
||||
status := "✓"
|
||||
if !spf.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s ", status)
|
||||
if spf.Domain != nil {
|
||||
fmt.Fprintf(writer, "Domain: %s", *spf.Domain)
|
||||
}
|
||||
if spf.AllQualifier != nil {
|
||||
fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if spf.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *spf.Record)
|
||||
}
|
||||
if spf.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM Records
|
||||
if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n DKIM Records:")
|
||||
for _, dkim := range *dns.DkimRecords {
|
||||
status := "✓"
|
||||
if !dkim.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain)
|
||||
if dkim.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dkim.Record)
|
||||
}
|
||||
if dkim.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC Record
|
||||
if dns.DmarcRecord != nil {
|
||||
fmt.Fprintln(writer, "\n DMARC Record:")
|
||||
status := "✓"
|
||||
if !dns.DmarcRecord.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid)
|
||||
if dns.DmarcRecord.Policy != nil {
|
||||
fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy)
|
||||
}
|
||||
if dns.DmarcRecord.SubdomainPolicy != nil {
|
||||
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if dns.DmarcRecord.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
||||
}
|
||||
if dns.DmarcRecord.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// BIMI Record
|
||||
if dns.BimiRecord != nil {
|
||||
fmt.Fprintln(writer, "\n BIMI Record:")
|
||||
status := "✓"
|
||||
if !dns.BimiRecord.Valid {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n",
|
||||
status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain)
|
||||
if dns.BimiRecord.LogoUrl != nil {
|
||||
fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl)
|
||||
}
|
||||
if dns.BimiRecord.VmcUrl != nil {
|
||||
fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl)
|
||||
}
|
||||
if dns.BimiRecord.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record)
|
||||
}
|
||||
if dns.BimiRecord.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// PTR Records
|
||||
if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 {
|
||||
fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:")
|
||||
for _, ptr := range *dns.PtrRecords {
|
||||
fmt.Fprintf(writer, " %s\n", ptr)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS Errors
|
||||
if dns.Errors != nil && len(*dns.Errors) > 0 {
|
||||
fmt.Fprintln(writer, "\n DNS Errors:")
|
||||
for _, err := range *dns.Errors {
|
||||
fmt.Fprintf(writer, " ! %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication Results
|
||||
if report.Authentication != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "AUTHENTICATION RESULTS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
auth := report.Authentication
|
||||
|
||||
// SPF
|
||||
if auth.Spf != nil {
|
||||
fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result)))
|
||||
if auth.Spf.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain)
|
||||
}
|
||||
if auth.Spf.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// DKIM
|
||||
if auth.Dkim != nil && len(*auth.Dkim) > 0 {
|
||||
fmt.Fprintln(writer, "\n DKIM:")
|
||||
for i, dkim := range *auth.Dkim {
|
||||
fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result)))
|
||||
if dkim.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s", *dkim.Domain)
|
||||
if dkim.Selector != nil {
|
||||
fmt.Fprintf(writer, ", selector: %s", *dkim.Selector)
|
||||
}
|
||||
fmt.Fprintf(writer, ")")
|
||||
}
|
||||
if dkim.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *dkim.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC
|
||||
if auth.Dmarc != nil {
|
||||
fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result)))
|
||||
if auth.Dmarc.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain)
|
||||
}
|
||||
if auth.Dmarc.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// ARC
|
||||
if auth.Arc != nil {
|
||||
fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result)))
|
||||
if auth.Arc.ChainLength != nil {
|
||||
fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength)
|
||||
}
|
||||
if auth.Arc.ChainValid != nil {
|
||||
fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid)
|
||||
}
|
||||
if auth.Arc.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// BIMI
|
||||
if auth.Bimi != nil {
|
||||
fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result)))
|
||||
if auth.Bimi.Domain != nil {
|
||||
fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain)
|
||||
}
|
||||
if auth.Bimi.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
|
||||
// IP Reverse
|
||||
if auth.Iprev != nil {
|
||||
fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result)))
|
||||
if auth.Iprev.Ip != nil {
|
||||
fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip)
|
||||
if auth.Iprev.Hostname != nil {
|
||||
fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname)
|
||||
}
|
||||
fmt.Fprintf(writer, ")")
|
||||
}
|
||||
if auth.Iprev.Details != nil {
|
||||
fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// Blacklist Results
|
||||
if report.Blacklists != nil && len(*report.Blacklists) > 0 {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "BLACKLIST CHECKS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
totalChecks := 0
|
||||
totalListed := 0
|
||||
for ip, checks := range *report.Blacklists {
|
||||
totalChecks += len(checks)
|
||||
fmt.Fprintf(writer, "\n IP Address: %s\n", ip)
|
||||
for _, check := range checks {
|
||||
status := "✓"
|
||||
if check.Listed {
|
||||
status = "✗"
|
||||
totalListed++
|
||||
}
|
||||
fmt.Fprintf(writer, " %s %s", status, check.Rbl)
|
||||
if check.Listed {
|
||||
fmt.Fprintf(writer, " - LISTED")
|
||||
if check.Response != nil {
|
||||
fmt.Fprintf(writer, " (%s)", *check.Response)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(writer, " - OK")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if check.Error != nil {
|
||||
fmt.Fprintf(writer, " ERROR: %s\n", *check.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks)
|
||||
}
|
||||
|
||||
// Header Analysis
|
||||
if report.HeaderAnalysis != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "HEADER ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
header := report.HeaderAnalysis
|
||||
|
||||
// Domain Alignment
|
||||
if header.DomainAlignment != nil {
|
||||
fmt.Fprintln(writer, "\n Domain Alignment:")
|
||||
align := header.DomainAlignment
|
||||
if align.FromDomain != nil {
|
||||
fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain)
|
||||
if align.FromOrgDomain != nil {
|
||||
fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
if align.ReturnPathDomain != nil {
|
||||
fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain)
|
||||
if align.ReturnPathOrgDomain != nil {
|
||||
fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
if align.Aligned != nil {
|
||||
fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned)
|
||||
}
|
||||
if align.RelaxedAligned != nil {
|
||||
fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned)
|
||||
}
|
||||
if align.Issues != nil && len(*align.Issues) > 0 {
|
||||
fmt.Fprintln(writer, " Issues:")
|
||||
for _, issue := range *align.Issues {
|
||||
fmt.Fprintf(writer, " - %s\n", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required/Important Headers
|
||||
if header.Headers != nil {
|
||||
fmt.Fprintln(writer, "\n Standard Headers:")
|
||||
importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"}
|
||||
for _, hdrName := range importantHeaders {
|
||||
if hdr, ok := (*header.Headers)[hdrName]; ok {
|
||||
status := "✗"
|
||||
if hdr.Present {
|
||||
status = "✓"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName))
|
||||
if hdr.Present {
|
||||
if hdr.Valid != nil && !*hdr.Valid {
|
||||
fmt.Fprintf(writer, "INVALID")
|
||||
} else {
|
||||
fmt.Fprintf(writer, "OK")
|
||||
}
|
||||
if hdr.Importance != nil {
|
||||
fmt.Fprintf(writer, " [%s]", *hdr.Importance)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(writer, "MISSING")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if hdr.Issues != nil && len(*hdr.Issues) > 0 {
|
||||
for _, issue := range *hdr.Issues {
|
||||
fmt.Fprintf(writer, " - %s\n", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header Issues
|
||||
if header.Issues != nil && len(*header.Issues) > 0 {
|
||||
fmt.Fprintln(writer, "\n Header Issues:")
|
||||
for _, issue := range *header.Issues {
|
||||
fmt.Fprintf(writer, " [%s] %s: %s\n",
|
||||
strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message)
|
||||
if issue.Advice != nil {
|
||||
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Received Chain
|
||||
if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 {
|
||||
fmt.Fprintln(writer, "\n Email Path (Received Chain):")
|
||||
for i, hop := range *header.ReceivedChain {
|
||||
fmt.Fprintf(writer, " [%d] ", i+1)
|
||||
if hop.From != nil {
|
||||
fmt.Fprintf(writer, "%s", *hop.From)
|
||||
if hop.Ip != nil {
|
||||
fmt.Fprintf(writer, " (%s)", *hop.Ip)
|
||||
}
|
||||
}
|
||||
if hop.By != nil {
|
||||
fmt.Fprintf(writer, " -> %s", *hop.By)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if hop.Timestamp != nil {
|
||||
fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SpamAssassin Results
|
||||
if report.Spamassassin != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
sa := report.Spamassassin
|
||||
fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore)
|
||||
if sa.IsSpam {
|
||||
fmt.Fprintf(writer, " (SPAM)")
|
||||
} else {
|
||||
fmt.Fprintf(writer, " (HAM)")
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
|
||||
if sa.Version != nil {
|
||||
fmt.Fprintf(writer, " Version: %s\n", *sa.Version)
|
||||
}
|
||||
|
||||
if len(sa.TestDetails) > 0 {
|
||||
fmt.Fprintln(writer, "\n Triggered Tests:")
|
||||
for _, test := range sa.TestDetails {
|
||||
scoreStr := "+"
|
||||
if test.Score < 0 {
|
||||
scoreStr = ""
|
||||
}
|
||||
fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name)
|
||||
if test.Description != nil {
|
||||
fmt.Fprintf(writer, "\n %s", *test.Description)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Analysis
|
||||
if report.ContentAnalysis != nil {
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "CONTENT ANALYSIS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
content := report.ContentAnalysis
|
||||
|
||||
// Basic content info
|
||||
fmt.Fprintln(writer, "\n Content Structure:")
|
||||
if content.HasPlaintext != nil {
|
||||
fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext)
|
||||
}
|
||||
if content.HasHtml != nil {
|
||||
fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml)
|
||||
}
|
||||
if content.TextToImageRatio != nil {
|
||||
fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio)
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
if content.HasUnsubscribeLink != nil {
|
||||
fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink)
|
||||
if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 {
|
||||
fmt.Fprintf(writer, " Unsubscribe Methods: ")
|
||||
for i, method := range *content.UnsubscribeMethods {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(writer, ", ")
|
||||
}
|
||||
fmt.Fprintf(writer, "%s", method)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// Links
|
||||
if content.Links != nil && len(*content.Links) > 0 {
|
||||
fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links))
|
||||
for _, link := range *content.Links {
|
||||
status := ""
|
||||
switch link.Status {
|
||||
case "valid":
|
||||
status = "✓"
|
||||
case "broken":
|
||||
status = "✗"
|
||||
case "suspicious":
|
||||
status = "⚠"
|
||||
case "redirected":
|
||||
status = "→"
|
||||
case "timeout":
|
||||
status = "⏱"
|
||||
}
|
||||
fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url)
|
||||
if link.HttpCode != nil {
|
||||
fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if link.RedirectChain != nil && len(*link.RedirectChain) > 0 {
|
||||
fmt.Fprintln(writer, " Redirect chain:")
|
||||
for _, url := range *link.RedirectChain {
|
||||
fmt.Fprintf(writer, " -> %s\n", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
if content.Images != nil && len(*content.Images) > 0 {
|
||||
fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images))
|
||||
missingAlt := 0
|
||||
trackingPixels := 0
|
||||
for _, img := range *content.Images {
|
||||
if !img.HasAlt {
|
||||
missingAlt++
|
||||
}
|
||||
if img.IsTrackingPixel != nil && *img.IsTrackingPixel {
|
||||
trackingPixels++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(writer, " Images with ALT text: %d/%d\n",
|
||||
len(*content.Images)-missingAlt, len(*content.Images))
|
||||
if trackingPixels > 0 {
|
||||
fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels)
|
||||
}
|
||||
}
|
||||
|
||||
// HTML Issues
|
||||
if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 {
|
||||
fmt.Fprintln(writer, "\n Content Issues:")
|
||||
for _, issue := range *content.HtmlIssues {
|
||||
fmt.Fprintf(writer, " [%s] %s: %s\n",
|
||||
strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message)
|
||||
if issue.Location != nil {
|
||||
fmt.Fprintf(writer, " Location: %s\n", *issue.Location)
|
||||
}
|
||||
if issue.Advice != nil {
|
||||
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n")
|
||||
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -41,6 +42,7 @@ type Config struct {
|
|||
Email EmailConfig
|
||||
Analysis AnalysisConfig
|
||||
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||
SurveyURL url.URL // URL for user feedback survey
|
||||
}
|
||||
|
||||
// DatabaseConfig contains database connection settings
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -43,3 +44,25 @@ func (i *StringArray) Set(value string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
type URL struct {
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func (i *URL) String() string {
|
||||
if i.URL != nil {
|
||||
return i.URL.String()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (i *URL) Set(value string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i.URL = *u
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
416
web/package-lock.json
generated
416
web/package-lock.json
generated
|
|
@ -15,7 +15,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@hey-api/openapi-ts": "0.85.2",
|
||||
"@hey-api/openapi-ts": "0.86.3",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.10",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
|
|
@ -655,13 +655,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hey-api/codegen-core": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz",
|
||||
"integrity": "sha512-c7VjBy/8ed0EVLNgaeS9Xxams1Tuv/WK/b4xXH3Qr4wjzYeJUtxOcoP8YdwNLavqKP8pGiuctjX2Z1Pwc4jMgQ==",
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.3.1.tgz",
|
||||
"integrity": "sha512-iLG9uRJdmQf83sCZ8WsDR6RXQep0X+D1t1mxuzhrSS9zVL4NvnjTQD6PNnQNPymJyss/mdPf7f7kbmcCK7DVmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=22.10.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
|
|
@ -671,9 +671,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hey-api/json-schema-ref-parser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.0.tgz",
|
||||
"integrity": "sha512-BMnIuhVgNmSudadw1GcTsP18Yk5l8FrYrg/OSYNxz0D2E0vf4D5e4j5nUbuY8MU6p1vp7ev0xrfP6A/NWazkzQ==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -690,27 +690,27 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts": {
|
||||
"version": "0.85.2",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.85.2.tgz",
|
||||
"integrity": "sha512-pNu+DOtjeXiGhMqSQ/mYadh6BuKR/QiucVunyA2P7w2uyxkfCJ9sHS20Y72KHXzB3nshKJ9r7JMirysoa50SJg==",
|
||||
"version": "0.86.3",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.86.3.tgz",
|
||||
"integrity": "sha512-hl8JYx1vSVGvPSqNSohUGfTFQu01Ib1uCmV0HUsk/ZYxHBaiKgOJNzUYOkID35lUcSh3bGcuj308s+p/+/qhVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/codegen-core": "^0.2.0",
|
||||
"@hey-api/json-schema-ref-parser": "1.2.0",
|
||||
"@hey-api/codegen-core": "^0.3.1",
|
||||
"@hey-api/json-schema-ref-parser": "1.2.1",
|
||||
"ansi-colors": "4.1.3",
|
||||
"c12": "3.3.0",
|
||||
"c12": "3.3.1",
|
||||
"color-support": "1.1.3",
|
||||
"commander": "13.0.0",
|
||||
"commander": "14.0.1",
|
||||
"handlebars": "4.7.8",
|
||||
"open": "10.1.2",
|
||||
"open": "10.2.0",
|
||||
"semver": "7.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-ts": "bin/index.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
|
|
@ -1608,39 +1608,40 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz",
|
||||
"integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/spy": "4.0.3",
|
||||
"@vitest/utils": "4.0.3",
|
||||
"chai": "^6.0.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz",
|
||||
"integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "4.0.3",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
|
|
@ -1652,42 +1653,41 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz",
|
||||
"integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz",
|
||||
"integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
"@vitest/utils": "4.0.3",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz",
|
||||
"integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/pretty-format": "4.0.3",
|
||||
"magic-string": "^0.30.19",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -1695,28 +1695,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz",
|
||||
"integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz",
|
||||
"integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/pretty-format": "4.0.3",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
|
|
@ -1806,16 +1802,6 @@
|
|||
"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",
|
||||
|
|
@ -1909,19 +1895,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz",
|
||||
"integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.1.tgz",
|
||||
"integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"confbox": "^0.2.2",
|
||||
"defu": "^6.1.4",
|
||||
"dotenv": "^17.2.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"exsolve": "^1.0.7",
|
||||
"giget": "^2.0.0",
|
||||
"jiti": "^2.5.1",
|
||||
"jiti": "^2.6.1",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"perfect-debounce": "^2.0.0",
|
||||
|
|
@ -1937,16 +1923,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -1958,18 +1934,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz",
|
||||
"integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1991,16 +1960,6 @@
|
|||
"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",
|
||||
|
|
@ -2068,13 +2027,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
|
||||
"integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz",
|
||||
"integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
|
|
@ -2157,16 +2116,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -2961,13 +2910,6 @@
|
|||
"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",
|
||||
|
|
@ -3090,13 +3032,6 @@
|
|||
"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",
|
||||
|
|
@ -3131,6 +3066,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",
|
||||
|
|
@ -3249,16 +3197,16 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz",
|
||||
"integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"is-wsl": "^3.1.0"
|
||||
"wsl-utils": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -3357,16 +3305,6 @@
|
|||
"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",
|
||||
|
|
@ -3382,13 +3320,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"
|
||||
|
|
@ -3863,19 +3802,6 @@
|
|||
"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",
|
||||
|
|
@ -4001,43 +3927,10 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"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==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -4246,42 +4139,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
|
||||
|
|
@ -4303,41 +4160,38 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz",
|
||||
"integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/expect": "4.0.3",
|
||||
"@vitest/mocker": "4.0.3",
|
||||
"@vitest/pretty-format": "4.0.3",
|
||||
"@vitest/runner": "4.0.3",
|
||||
"@vitest/snapshot": "4.0.3",
|
||||
"@vitest/spy": "4.0.3",
|
||||
"@vitest/utils": "4.0.3",
|
||||
"debug": "^4.4.3",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"picomatch": "^4.0.3",
|
||||
"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",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
|
|
@ -4345,9 +4199,11 @@
|
|||
"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",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.3",
|
||||
"@vitest/browser-preview": "4.0.3",
|
||||
"@vitest/browser-webdriverio": "4.0.3",
|
||||
"@vitest/ui": "4.0.3",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
|
@ -4361,7 +4217,13 @@
|
|||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
|
|
@ -4375,19 +4237,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",
|
||||
|
|
@ -4445,19 +4294,20 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@hey-api/openapi-ts": "0.85.2",
|
||||
"@hey-api/openapi-ts": "0.86.3",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.10",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ package web
|
|||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
|
|
@ -54,6 +55,14 @@ func init() {
|
|||
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||
appConfig := map[string]interface{}{}
|
||||
|
||||
if cfg.ReportRetention > 0 {
|
||||
appConfig["report_retention"] = cfg.ReportRetention
|
||||
}
|
||||
|
||||
if cfg.SurveyURL.Host != "" {
|
||||
appConfig["survey_url"] = cfg.SurveyURL.String()
|
||||
}
|
||||
|
||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||
log.Println("Unable to generate JSON config to inject in web application")
|
||||
} else {
|
||||
|
|
@ -123,13 +132,14 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
|
||||
v, _ := ioutil.ReadAll(resp.Body)
|
||||
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
}
|
||||
|
|
@ -149,14 +159,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
|||
f, _ := Assets.Open("index.html")
|
||||
v, _ := ioutil.ReadAll(f)
|
||||
|
||||
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
}
|
||||
|
||||
// Serve template
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Head": CustomHeadHTML,
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="happyDeliver - Test Your Email Deliverability" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
|
||||
/>
|
||||
<meta property="og:image" content="/img/og.webp" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="happyDeliver - Test Your Email Deliverability" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
|
||||
/>
|
||||
<meta name="twitter:image" content="/img/og.webp" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type { Authentication, DNSResults, ReportSummary } from "$lib/api/types.gen";
|
||||
import type { AuthenticationResults, DnsResults } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
authentication: Authentication;
|
||||
authentication: AuthenticationResults;
|
||||
authenticationGrade?: string;
|
||||
authenticationScore?: number;
|
||||
dnsResults?: DNSResults;
|
||||
dnsResults?: DnsResults;
|
||||
}
|
||||
|
||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||
|
|
@ -132,10 +132,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>SPF</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">SPF record is required for proper email authentication</div>
|
||||
|
|
@ -171,10 +171,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DKIM</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">DKIM signature is required for proper email authentication</div>
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- X-Aligned-From (Disabled) -->
|
||||
{#if false && authentication.x_aligned_from}
|
||||
{#if authentication.x_aligned_from}
|
||||
<div class="list-group-item" id="authentication-x-aligned-from">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.x_aligned_from.result, false)} {getAuthResultClass(authentication.x_aligned_from.result, false)} me-2 fs-5"></i>
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
<span class="text-muted">{authentication.dmarc.domain}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet DMARCPolicy(policy)}
|
||||
{#snippet DMARCPolicy(policy: string)}
|
||||
<div class="small">
|
||||
<strong>Policy:</strong>
|
||||
<span
|
||||
|
|
@ -268,10 +268,10 @@
|
|||
</div>
|
||||
{/snippet}
|
||||
{#if authentication.dmarc.result != "none"}
|
||||
{#if authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
|
||||
{#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}
|
||||
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
|
||||
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -280,10 +280,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
|
||||
<i class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass('missing', true)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>DMARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
|
||||
{getAuthResultText('missing')}
|
||||
</span>
|
||||
<div class="text-muted small">DMARC policy is required for proper email authentication</div>
|
||||
|
|
@ -296,10 +296,10 @@
|
|||
<div class="list-group-item" id="authentication-bimi">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.bimi && authentication.bimi.result != "none"}
|
||||
<i class="bi {getAuthResultIcon(authentication.bimi.result)} {getAuthResultClass(authentication.bimi.result)} me-2 fs-5"></i>
|
||||
<i class="bi {getAuthResultIcon(authentication.bimi.result, false)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>BIMI</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result)}">
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result, false)}">
|
||||
{authentication.bimi.result}
|
||||
</span>
|
||||
{#if authentication.bimi.details}
|
||||
|
|
@ -335,10 +335,10 @@
|
|||
{#if authentication.arc}
|
||||
<div class="list-group-item" id="authentication-arc">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.arc.result)} {getAuthResultClass(authentication.arc.result)} me-2 fs-5"></i>
|
||||
<i class="bi {getAuthResultIcon(authentication.arc.result, false)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>ARC</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result)}">
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result, false)}">
|
||||
{authentication.arc.result}
|
||||
</span>
|
||||
{#if authentication.arc.chain_length}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { BIMIRecord } from "$lib/api/types.gen";
|
||||
import type { BimiRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
bimiRecord?: BIMIRecord;
|
||||
bimiRecord?: BimiRecord;
|
||||
}
|
||||
|
||||
let { bimiRecord }: Props = $props();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type { RBLCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import EmailPathCard from "./EmailPathCard.svelte";
|
||||
|
||||
interface Props {
|
||||
blacklists: Record<string, RBLCheck[]>;
|
||||
blacklists: Record<string, BlacklistCheck[]>;
|
||||
blacklistGrade?: string;
|
||||
blacklistScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { DKIMRecord } from "$lib/api/types.gen";
|
||||
import type { DkimRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
dkimRecords?: DKIMRecord[];
|
||||
dkimRecords?: DkimRecord[];
|
||||
}
|
||||
|
||||
let { dkimRecords }: Props = $props();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { DMARCRecord } from "$lib/api/types.gen";
|
||||
import type { DmarcRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
dmarcRecord?: DMARCRecord;
|
||||
dmarcRecord?: DmarcRecord;
|
||||
}
|
||||
|
||||
let { dmarcRecord }: Props = $props();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { DomainAlignment, DNSResults, ReceivedHop } from "$lib/api/types.gen";
|
||||
import type { DomainAlignment, DnsResults, ReceivedHop } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
interface Props {
|
||||
domainAlignment?: DomainAlignment;
|
||||
dnsResults?: DNSResults;
|
||||
dnsResults?: DnsResults;
|
||||
dnsGrade?: string;
|
||||
dnsScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
|
|
|
|||
158
web/src/lib/components/ErrorDisplay.svelte
Normal file
158
web/src/lib/components/ErrorDisplay.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
status: number;
|
||||
message?: string;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
let { status, message, showActions = true }: Props = $props();
|
||||
|
||||
function getErrorTitle(status: number): string {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "Page Not Found";
|
||||
case 403:
|
||||
return "Access Denied";
|
||||
case 429:
|
||||
return "Too Many Requests";
|
||||
case 500:
|
||||
return "Server Error";
|
||||
case 503:
|
||||
return "Service Unavailable";
|
||||
default:
|
||||
return "Something Went Wrong";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorDescription(status: number): string {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "The page you're looking for doesn't exist or has been moved.";
|
||||
case 403:
|
||||
return "You don't have permission to access this resource.";
|
||||
case 429:
|
||||
return "You've made too many requests. Please wait a moment and try again.";
|
||||
case 500:
|
||||
return "Our server encountered an error while processing your request.";
|
||||
case 503:
|
||||
return "The service is temporarily unavailable. Please try again later.";
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorIcon(status: number): string {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "bi-search";
|
||||
case 403:
|
||||
return "bi-shield-lock";
|
||||
case 429:
|
||||
return "bi-hourglass-split";
|
||||
case 500:
|
||||
return "bi-exclamation-triangle";
|
||||
case 503:
|
||||
return "bi-clock-history";
|
||||
default:
|
||||
return "bi-exclamation-circle";
|
||||
}
|
||||
}
|
||||
|
||||
let defaultDescription = $derived(getErrorDescription(status));
|
||||
let displayMessage = $derived(message || defaultDescription);
|
||||
</script>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center fade-in">
|
||||
<!-- Error Icon -->
|
||||
<div class="error-icon-wrapper mb-4">
|
||||
<i class="bi {getErrorIcon(status)} text-danger"></i>
|
||||
</div>
|
||||
|
||||
<!-- Error Status -->
|
||||
<h1 class="display-1 fw-bold text-primary mb-3">{status}</h1>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h2 class="fw-bold mb-3">{getErrorTitle(status)}</h2>
|
||||
|
||||
<!-- Error Description -->
|
||||
<p class="text-muted mb-4">{getErrorDescription(status)}</p>
|
||||
|
||||
<!-- Error Message (if available and different from default) -->
|
||||
{#if message && message !== defaultDescription}
|
||||
<div class="alert alert-light border mb-4" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{#if showActions}
|
||||
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-center">
|
||||
<a href="/" class="btn btn-primary btn-lg px-4">
|
||||
<i class="bi bi-house-door me-2"></i>
|
||||
Go Home
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-outline-primary btn-lg px-4"
|
||||
onclick={() => window.history.back()}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Additional Help -->
|
||||
{#if status === 404 && showActions}
|
||||
<div class="mt-5">
|
||||
<p class="text-muted small mb-2">Looking for something specific?</p>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-center">
|
||||
<a href="/" class="badge bg-light text-dark text-decoration-none">Home</a>
|
||||
<a href="/#features" class="badge bg-light text-dark text-decoration-none">
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/happyDomain/happydeliver"
|
||||
class="badge bg-light text-dark text-decoration-none"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-icon-wrapper {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: normal;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
grade?: string;
|
||||
score: number;
|
||||
score?: number;
|
||||
size?: "inline" | "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import type { AuthResult, DMARCRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||
import type { AuthResult, DmarcRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
dmarcRecord: DMARCRecord;
|
||||
dmarcRecord?: DmarcRecord;
|
||||
headerAnalysis: HeaderAnalysis;
|
||||
headerGrade?: string;
|
||||
headerScore?: number;
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
{#if xAlignedFrom}
|
||||
<i class="bi {xAlignedFrom == "pass" ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger'}"></i>
|
||||
<i class="bi {xAlignedFrom.result == "pass" ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger'}"></i>
|
||||
{:else}
|
||||
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import type { MXRecord } from "$lib/api/types.gen";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import type { MxRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
class: ClassValue;
|
||||
mxRecords: MXRecord[];
|
||||
mxRecords: MxRecord[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
interface Props {
|
||||
test: Test;
|
||||
nbfetch: number;
|
||||
nextfetch: number;
|
||||
fetching?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
interface Props {
|
||||
spamassassin: SpamAssassinResult;
|
||||
spamGrade: string;
|
||||
spamScore: number;
|
||||
spamGrade?: string;
|
||||
spamScore?: number;
|
||||
}
|
||||
|
||||
let { spamassassin, spamGrade, spamScore }: Props = $props();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { SPFRecord } from "$lib/api/types.gen";
|
||||
import type { SpfRecord } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
spfRecords?: SPFRecord[];
|
||||
spfRecords?: SpfRecord[];
|
||||
}
|
||||
|
||||
let { spfRecords }: Props = $props();
|
||||
|
||||
// Compute overall validity
|
||||
const spfIsValid = $derived(
|
||||
spfRecords?.reduce((acc, r) => acc && r.valid, true) ?? false
|
||||
);
|
||||
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 != "-"
|
||||
spfRecords &&
|
||||
spfRecords.length > 0 &&
|
||||
spfRecords.filter((r) => !r.record?.includes(" redirect="))[0]?.all_qualifier != "-",
|
||||
);
|
||||
</script>
|
||||
|
||||
|
|
@ -58,13 +58,13 @@
|
|||
{#if spf.all_qualifier}
|
||||
<div class="mb-2">
|
||||
<strong>All Mechanism Policy:</strong>
|
||||
{#if spf.all_qualifier === '-'}
|
||||
{#if spf.all_qualifier === "-"}
|
||||
<span class="badge bg-success">Strict (-all)</span>
|
||||
{:else if spf.all_qualifier === '~'}
|
||||
{:else if spf.all_qualifier === "~"}
|
||||
<span class="badge bg-warning">Softfail (~all)</span>
|
||||
{:else if spf.all_qualifier === '+'}
|
||||
{:else if spf.all_qualifier === "+"}
|
||||
<span class="badge bg-danger">Pass (+all)</span>
|
||||
{:else if spf.all_qualifier === '?'}
|
||||
{:else if spf.all_qualifier === "?"}
|
||||
<span class="badge bg-warning">Neutral (?all)</span>
|
||||
{/if}
|
||||
{#if index === 0 || (index === 1 && spfRecords[0].record?.includes('redirect='))}
|
||||
|
|
|
|||
|
|
@ -5,36 +5,39 @@
|
|||
interface TextSegment {
|
||||
text: string;
|
||||
highlight?: {
|
||||
color: "good" | "warning" | "danger";
|
||||
color?: "good" | "warning" | "danger";
|
||||
bold?: boolean;
|
||||
emphasis?: boolean;
|
||||
monospace?: boolean;
|
||||
};
|
||||
link?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
report: Report;
|
||||
}
|
||||
|
||||
let { report }: Props = $props();
|
||||
let { children, report }: Props = $props();
|
||||
|
||||
function buildSummary(): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
|
||||
// Email sender information
|
||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||
const hasDkim = report.authentication?.dkim && report.authentication.dkim.length > 0;
|
||||
const dkimPassed = hasDkim && report.authentication.dkim.some(d => d.result === "pass");
|
||||
const hasDkim = report.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 +50,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 +68,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 +98,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 +122,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 +139,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 +153,7 @@
|
|||
segments.push({
|
||||
text: iprevResult.result,
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-ptr"
|
||||
link: "#dns-ptr",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -152,20 +162,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 +188,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 +197,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 +225,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 +254,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 +265,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) {
|
||||
|
|
@ -265,24 +283,35 @@
|
|||
segments.push({
|
||||
text: "BIMI",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#dns-bimi"
|
||||
link: "#dns-bimi",
|
||||
});
|
||||
segments.push({ text: " for brand indicator display" });
|
||||
} else if (bimiResult && bimiResult.details.indexOf("(No BIMI records found)") >= 0) {
|
||||
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
|
||||
segments.push({ text: " declined to participate" });
|
||||
} else {
|
||||
segments.push({ text: " for brand indicator display" });
|
||||
}
|
||||
} else if (
|
||||
bimiResult &&
|
||||
bimiResult.details &&
|
||||
bimiResult.details.indexOf("(No BIMI records found)") >= 0
|
||||
) {
|
||||
segments.push({ text: ". Your domain has no " });
|
||||
segments.push({
|
||||
text: "BIMI record",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-bimi"
|
||||
link: "#dns-bimi",
|
||||
});
|
||||
segments.push({ text: ", you could " });
|
||||
segments.push({ text: "add a record to decline participation", highlight: { bold: true } });
|
||||
segments.push({
|
||||
text: "add a record to decline participation",
|
||||
highlight: { bold: true },
|
||||
});
|
||||
} else if (bimiResult || bimiRecord) {
|
||||
segments.push({ text: ". Your domain has " });
|
||||
segments.push({
|
||||
text: "BIMI configured with issues",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-bimi"
|
||||
link: "#dns-bimi",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -293,19 +322,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 +347,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 +380,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 +388,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 +445,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,35 +453,21 @@
|
|||
return "text-warning";
|
||||
case "danger":
|
||||
return "text-danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const summarySegments = $derived(buildSummary());
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.summary-link {
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title mb-3">
|
||||
<i class="bi bi-card-text me-2"></i>
|
||||
Summary
|
||||
</h5>
|
||||
<p class="card-text text-muted mb-0" style="line-height: 1.8;">
|
||||
<p class="card-text text-muted" class:mb-0={!children} style="line-height: 1.8;">
|
||||
{#each summarySegments as segment}
|
||||
{#if segment.link}
|
||||
<a
|
||||
|
|
@ -458,5 +486,22 @@
|
|||
{/each}
|
||||
Overall, your email received a grade <GradeDisplay grade={report.grade} score={report.score} size="inline" />{#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 🔽
|
||||
</p>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.summary-link {
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
96
web/src/lib/components/TinySurvey.svelte
Normal file
96
web/src/lib/components/TinySurvey.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
|
||||
import { appConfig } from "$lib/config";
|
||||
|
||||
interface Props {
|
||||
class: ClassValue;
|
||||
question?: Snippet;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
let { class: className, question, source }: Props = $props();
|
||||
|
||||
let step = $state<number>(0);
|
||||
|
||||
interface Responses {
|
||||
id: string;
|
||||
stars: number;
|
||||
source?: string;
|
||||
avis?: string;
|
||||
}
|
||||
|
||||
const responses = $state<Responses>({
|
||||
id: btoa(String(Math.random() * 100)),
|
||||
stars: 1,
|
||||
});
|
||||
|
||||
function submit(e: SubmitEvent): void {
|
||||
e.preventDefault();
|
||||
|
||||
step += 1;
|
||||
|
||||
if (source) {
|
||||
responses.source = source;
|
||||
}
|
||||
|
||||
if ($appConfig.surveyUrl) {
|
||||
fetch($appConfig.surveyUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(responses),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $appConfig.surveyUrl}
|
||||
<form class={className} onsubmit={submit}>
|
||||
{#if step === 0}
|
||||
{#if question}{@render question()}{:else}
|
||||
<p class="mb-1 small">Help us to design a better tool, rate this report!</p>
|
||||
{/if}
|
||||
<div class="btn-group" role="group" aria-label="Rate your level of happyness">
|
||||
{#each [...Array(5).keys()] as i}
|
||||
<button
|
||||
class="btn btn-lg px-1 pb-2 pt-1"
|
||||
class:btn-outline-success={responses.stars <= i}
|
||||
class:text-dark={responses.stars <= i}
|
||||
class:btn-success={responses.stars > i}
|
||||
style="line-height: 1em"
|
||||
onfocusin={() => (responses.stars = i + 1)}
|
||||
onmouseenter={() => (responses.stars = i + 1)}
|
||||
aria-label={`${i + 1} star${i + 1 > 1 ? "s" : ""}`}
|
||||
>
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
<p>
|
||||
{#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}
|
||||
</p>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
autofocus
|
||||
class="form-control"
|
||||
placeholder="Your thoughts..."
|
||||
id="q6"
|
||||
rows="2"
|
||||
bind:value={responses.avis}
|
||||
></textarea>
|
||||
<button class="btn btn-success mt-1"> Send! </button>
|
||||
{:else if step === 2}
|
||||
<p class="fw-bold mb-0">
|
||||
Thank you so much for taking the time to share your feedback!
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
|
@ -13,3 +13,5 @@ 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";
|
||||
export { default as ErrorDisplay } from "./ErrorDisplay.svelte";
|
||||
|
|
|
|||
50
web/src/lib/config.ts
Normal file
50
web/src/lib/config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// 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 <contact@happydomain.org>.
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
interface AppConfig {
|
||||
report_retention?: number;
|
||||
surveyUrl?: string;
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
report_retention: 0,
|
||||
surveyUrl: "",
|
||||
};
|
||||
|
||||
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<AppConfig>(initialConfig);
|
||||
|
|
@ -7,8 +7,8 @@ export class NotAuthorizedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
async function customFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
const response = await fetch(url, init);
|
||||
async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (response.status === 400) {
|
||||
const json = await response.json();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { ErrorDisplay } from "$lib/components";
|
||||
|
||||
let status = $derived($page.status);
|
||||
let message = $derived($page.error?.message || "An unexpected error occurred");
|
||||
|
|
@ -10,6 +11,8 @@
|
|||
return "Page Not Found";
|
||||
case 403:
|
||||
return "Access Denied";
|
||||
case 429:
|
||||
return "Too Many Requests";
|
||||
case 500:
|
||||
return "Server Error";
|
||||
case 503:
|
||||
|
|
@ -18,36 +21,6 @@
|
|||
return "Something Went Wrong";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorDescription(status: number): string {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "The page you're looking for doesn't exist or has been moved.";
|
||||
case 403:
|
||||
return "You don't have permission to access this resource.";
|
||||
case 500:
|
||||
return "Our server encountered an error while processing your request.";
|
||||
case 503:
|
||||
return "The service is temporarily unavailable. Please try again later.";
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorIcon(status: number): string {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "bi-search";
|
||||
case 403:
|
||||
return "bi-shield-lock";
|
||||
case 500:
|
||||
return "bi-exclamation-triangle";
|
||||
case 503:
|
||||
return "bi-clock-history";
|
||||
default:
|
||||
return "bi-exclamation-circle";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -55,96 +28,5 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center fade-in">
|
||||
<!-- Error Icon -->
|
||||
<div class="error-icon-wrapper mb-4">
|
||||
<i class="bi {getErrorIcon(status)} text-danger"></i>
|
||||
</div>
|
||||
|
||||
<!-- Error Status -->
|
||||
<h1 class="display-1 fw-bold text-primary mb-3">{status}</h1>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h2 class="fw-bold mb-3">{getErrorTitle(status)}</h2>
|
||||
|
||||
<!-- Error Description -->
|
||||
<p class="text-muted mb-4">{getErrorDescription(status)}</p>
|
||||
|
||||
<!-- Error Message (if available) -->
|
||||
{#if message !== getErrorDescription(status)}
|
||||
<div class="alert alert-light border mb-4" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-center">
|
||||
<a href="/" class="btn btn-primary btn-lg px-4">
|
||||
<i class="bi bi-house-door me-2"></i>
|
||||
Go Home
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-outline-primary btn-lg px-4"
|
||||
onclick={() => window.history.back()}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Additional Help -->
|
||||
{#if status === 404}
|
||||
<div class="mt-5">
|
||||
<p class="text-muted small mb-2">Looking for something specific?</p>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-center">
|
||||
<a href="/" class="badge bg-light text-dark text-decoration-none">Home</a>
|
||||
<a href="/#features" class="badge bg-light text-dark text-decoration-none"
|
||||
>Features</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/happyDomain/happydeliver"
|
||||
class="badge bg-light text-dark text-decoration-none"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ErrorDisplay {status} {message} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-icon-wrapper {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: normal;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@
|
|||
</h3>
|
||||
<ul class="footer-links">
|
||||
<li><a href="/#features">Features</a></li>
|
||||
<li><a href="#">Download</a></li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/happyDomain/happydeliver/releases"
|
||||
target="_blank">Download</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/happyDomain/happydeliver/" target="_blank">
|
||||
GitHub
|
||||
|
|
@ -73,16 +78,32 @@
|
|||
class="d-flex flex-wrap justify-content-between footer-links"
|
||||
style="gap: .5em; font-size: 2em"
|
||||
>
|
||||
<a href="https://framagit.org/happyDomain/happydeliver" target="_blank">
|
||||
<a
|
||||
href="https://framagit.org/happyDomain/happydeliver"
|
||||
target="_blank"
|
||||
aria-label="Visit our GitLab repository"
|
||||
>
|
||||
<i class="bi bi-gitlab"></i>
|
||||
</a>
|
||||
<a href="https://github.com/happyDomain/happydeliver" target="_blank">
|
||||
<a
|
||||
href="https://github.com/happyDomain/happydeliver"
|
||||
target="_blank"
|
||||
aria-label="Visit our GitHub repository"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
</a>
|
||||
<a href="https://feedback.happydomain.org/" target="_blank">
|
||||
<a
|
||||
href="https://feedback.happydomain.org/"
|
||||
target="_blank"
|
||||
aria-label="Share your feedback"
|
||||
>
|
||||
<i class="bi bi-lightbulb-fill"></i>
|
||||
</a>
|
||||
<a href="https://floss.social/@happyDomain" target="_blank">
|
||||
<a
|
||||
href="https://floss.social/@happyDomain"
|
||||
target="_blank"
|
||||
aria-label="Follow us on Mastodon"
|
||||
>
|
||||
<i class="bi bi-mastodon"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { createTest as apiCreateTest } from "$lib/api";
|
||||
import { appConfig } from "$lib/config";
|
||||
import { FeatureCard, HowItWorksStep } from "$lib/components";
|
||||
|
||||
let loading = $state(false);
|
||||
|
|
@ -21,7 +22,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
const features = [
|
||||
function getRetentionTimeText(): string {
|
||||
if (!$appConfig.report_retention) return "ever";
|
||||
|
||||
const seconds = $appConfig.report_retention / 1000000000;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
|
||||
if (months >= 1) {
|
||||
return months === 1 ? "1 month" : `${months} months`;
|
||||
} else if (weeks >= 1) {
|
||||
return weeks === 1 ? "1 week" : `${weeks} weeks`;
|
||||
} else if (days >= 1) {
|
||||
return days === 1 ? "1 day" : `${days} days`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return hours === 1 ? "1 hour" : `${hours} hours`;
|
||||
}
|
||||
}
|
||||
|
||||
const features = $derived([
|
||||
{
|
||||
icon: "bi-shield-check",
|
||||
title: "Authentication",
|
||||
|
|
@ -30,16 +51,31 @@
|
|||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-patch-check",
|
||||
icon: "bi-building-check",
|
||||
title: "BIMI Support",
|
||||
description:
|
||||
"Brand Indicators for Message Identification - verify your brand logo configuration.",
|
||||
variant: "info" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-link-45deg",
|
||||
title: "ARC Verification",
|
||||
description:
|
||||
"Authenticated Received Chain validation for forwarded emails and mailing lists.",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-check2-circle",
|
||||
title: "Domain Alignment",
|
||||
description:
|
||||
"Verify alignment between From, Return-Path, and DKIM domains for DMARC compliance.",
|
||||
variant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-globe",
|
||||
title: "DNS Records",
|
||||
description: "Verify MX, SPF, DKIM, DMARC, and BIMI records are properly configured.",
|
||||
description:
|
||||
"Verify PTR, MX, SPF, DKIM, DMARC, and BIMI records are properly configured.",
|
||||
variant: "success" as const,
|
||||
},
|
||||
{
|
||||
|
|
@ -54,32 +90,32 @@
|
|||
description: "Check if your IP is listed in major DNS-based blacklists (RBLs).",
|
||||
variant: "danger" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-file-text",
|
||||
title: "Content Analysis",
|
||||
description: "HTML structure, link validation, image analysis, and more.",
|
||||
variant: "info" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-card-heading",
|
||||
title: "Header Quality",
|
||||
description: "Validate required headers, check for missing fields and alignment.",
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-file-text",
|
||||
title: "Content Analysis",
|
||||
description: "HTML structure, link validation, image analysis, and more.",
|
||||
variant: "info" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-bar-chart",
|
||||
title: "Detailed Scoring",
|
||||
description:
|
||||
"0-10 deliverability score with breakdown by category and recommendations.",
|
||||
"A to F deliverability grade with breakdown by category and recommendations.",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-lock",
|
||||
title: "Privacy First",
|
||||
description: "Self-hosted solution, your data never leaves your infrastructure.",
|
||||
description: `Self-hosted solution, your data never leaves your infrastructure. Reports retained for ${getRetentionTimeText()}.`,
|
||||
variant: "success" as const,
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
|
|
@ -115,7 +151,7 @@
|
|||
and more. Open-source, self-hosted, and privacy-focused.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-success btn-lg px-5 py-3 shadow"
|
||||
class="btn btn-success btn-lg px-5 py-3 shadow cta-button"
|
||||
onclick={createTest}
|
||||
disabled={loading}
|
||||
>
|
||||
|
|
@ -152,7 +188,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="row g-4 justify-content-center">
|
||||
{#each features as feature}
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<FeatureCard {...feature} />
|
||||
|
|
@ -202,10 +238,20 @@
|
|||
|
||||
<style>
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(102, 126, 234, 0.85) 0%, rgba(118, 75, 162, 0.9) 100%),
|
||||
url("/img/report.webp");
|
||||
background-size: cover;
|
||||
background-position: center 25%;
|
||||
background-repeat: no-repeat;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.hero p {
|
||||
text-shadow: black 0 0 1px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
|
@ -220,4 +266,31 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
position: relative;
|
||||
animation: pulse 2s infinite;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.cta-button:hover:not(:disabled) {
|
||||
animation: none;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cta-button:disabled {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 1rem 2rem rgba(25, 135, 84, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
DnsRecordsCard,
|
||||
BlacklistCard,
|
||||
ContentAnalysisCard,
|
||||
HeaderAnalysisCard
|
||||
HeaderAnalysisCard,
|
||||
TinySurvey,
|
||||
ErrorDisplay,
|
||||
} from "$lib/components";
|
||||
|
||||
let testId = $derived(page.params.test);
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
let report = $state<Report | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let errorStatus = $state<number>(500);
|
||||
let reanalyzing = $state(false);
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let nextfetch = $state(23);
|
||||
|
|
@ -27,12 +30,47 @@
|
|||
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;
|
||||
|
||||
if (nbfetch > 0) {
|
||||
nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5));
|
||||
}
|
||||
nbfetch += 1;
|
||||
|
||||
// Clear any previous errors
|
||||
error = null;
|
||||
|
||||
// Set fetching state and ensure minimum 500ms display time
|
||||
fetching = true;
|
||||
const startTime = Date.now();
|
||||
|
|
@ -49,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 {
|
||||
|
|
@ -89,8 +132,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
let lastTestId = null;
|
||||
function testChange(newTestId) {
|
||||
let lastTestId: string | null = null;
|
||||
function testChange(newTestId: string) {
|
||||
if (lastTestId != newTestId) {
|
||||
lastTestId = newTestId;
|
||||
test = null;
|
||||
|
|
@ -100,8 +143,11 @@
|
|||
}
|
||||
|
||||
$effect(() => {
|
||||
testChange(page.params.test);
|
||||
})
|
||||
const newTestId = page.params.test;
|
||||
if (newTestId) {
|
||||
testChange(newTestId);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
|
|
@ -118,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;
|
||||
}
|
||||
|
|
@ -128,9 +176,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 +188,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{report ? `Test of ${report.dns_results.from_domain} ${report.test_id.slice(0, 7)}` : (test ? `Test ${test.id.slice(0, 7)}` : "Loading...")} - happyDeliver</title>
|
||||
<title>{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</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container py-5">
|
||||
|
|
@ -156,14 +204,7 @@
|
|||
<p class="mt-3 text-muted">Loading test...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorDisplay status={errorStatus} message={error} showActions={false} />
|
||||
{:else if test && test.status !== "analyzed"}
|
||||
<!-- Pending State -->
|
||||
<PendingState
|
||||
|
|
@ -231,7 +272,11 @@
|
|||
<!-- Summary -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<SummaryCard {report} />
|
||||
<SummaryCard {report}>
|
||||
<div class="d-flex justify-content-end me-lg-5">
|
||||
<TinySurvey class="bg-primary-subtle rounded-4 p-3 text-center" />
|
||||
</div>
|
||||
</SummaryCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,11 +328,11 @@
|
|||
<div class="row mb-4" id="header">
|
||||
<div class="col-12">
|
||||
<HeaderAnalysisCard
|
||||
dmarcRecord={report.dns_results.dmarc_record}
|
||||
dmarcRecord={report.dns_results?.dmarc_record}
|
||||
headerAnalysis={report.header_analysis}
|
||||
headerGrade={report.summary?.header_grade}
|
||||
headerScore={report.summary?.header_score}
|
||||
xAlignedFrom={report.authentication.x_aligned_from}
|
||||
xAlignedFrom={report.authentication?.x_aligned_from}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -348,23 +393,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;
|
||||
}
|
||||
|
|
|
|||
BIN
web/static/img/og.webp
Normal file
BIN
web/static/img/og.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
web/static/img/report.webp
Normal file
BIN
web/static/img/report.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Loading…
Add table
Add a link
Reference in a new issue