Update dependency vitest to v4 #13

Open
renovate-bot wants to merge 13 commits from renovate/major-vitest-monorepo into master
36 changed files with 1450 additions and 614 deletions

View file

@ -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
![A sample deliverability report](web/static/img/report.webp)
## 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
View file

@ -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
View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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
View file

@ -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": {

View file

@ -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",

View file

@ -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())
}

View file

@ -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">

View file

@ -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}

View file

@ -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();

View file

@ -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[];

View file

@ -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();

View file

@ -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();

View file

@ -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[];

View 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>

View file

@ -1,7 +1,7 @@
<script lang="ts">
interface Props {
grade?: string;
score: number;
score?: number;
size?: "inline" | "small" | "medium" | "large";
}

View file

@ -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}

View file

@ -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;
}

View file

@ -8,6 +8,8 @@
interface Props {
test: Test;
nbfetch: number;
nextfetch: number;
fetching?: boolean;
}

View file

@ -5,8 +5,8 @@
interface Props {
spamassassin: SpamAssassinResult;
spamGrade: string;
spamScore: number;
spamGrade?: string;
spamScore?: number;
}
let { spamassassin, spamGrade, spamScore }: Props = $props();

View file

@ -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='))}

View file

@ -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>

View 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}

View file

@ -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
View 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);

View file

@ -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();

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
web/static/img/report.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB