mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-28 06:56:06 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294faa4f10 | ||
|
|
25b9ebd90e | ||
|
|
87472746a9 | ||
|
|
9dd1e99f52 | ||
|
|
fcca56625f | ||
|
|
3a4c7766e9 | ||
|
|
dc9b8d54b7 | ||
|
|
b8cc1bc415 | ||
|
|
0fee30d3df | ||
|
|
1200ad0506 | ||
|
|
c12c6458a3 | ||
|
|
16f0c1416d | ||
|
|
0e3441aba9 | ||
|
|
2dc2145db7 | ||
|
|
9c2359eee5 | ||
|
|
7b22d6a5f9 | ||
|
|
fcd964501a | ||
|
|
3a222dd147 | ||
|
|
857cf78984 | ||
|
|
6802e24e55 | ||
|
|
deaab34cdd | ||
|
|
ee9863289a | ||
|
|
70037e96f4 | ||
|
|
fc0b016549 | ||
|
|
140633718c | ||
|
|
f40911c580 | ||
|
|
3073ef9afe | ||
|
|
804d49b7ca | ||
|
|
7d29dff5e7 | ||
|
|
bc8a737d4f | ||
|
|
b99be839a0 | ||
|
|
c1db706677 | ||
|
|
ab3fc5ead7 | ||
|
|
a72d42c8d4 | ||
|
|
f8052e1d56 | ||
|
|
267bf8b639 | ||
|
|
51e327f259 | ||
|
|
bb6bdf629d | ||
|
|
a0a4ebb943 | ||
|
|
ba00ea5a21 | ||
|
|
2afc52c6fe | ||
|
|
5e9c522402 | ||
|
|
7bb330a07a | ||
|
|
ffb3067680 |
11
.github/workflows/build-docker-edge.yml
vendored
11
.github/workflows/build-docker-edge.yml
vendored
@@ -9,6 +9,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # required for github-action-get-previous-tag
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -29,7 +31,12 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- uses: benjlevesque/short-sha@v3.0
|
||||
- name: Get previous git tag
|
||||
uses: WyriHaximus/github-action-get-previous-tag@v2
|
||||
id: previous-tag
|
||||
|
||||
- name: Get short SHA
|
||||
uses: benjlevesque/short-sha@v3.0
|
||||
id: short-sha
|
||||
|
||||
- name: Build and push
|
||||
@@ -38,7 +45,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
|
||||
"VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}"
|
||||
push: true
|
||||
tags: |
|
||||
axllent/mailpit:edge
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,6 +2,64 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.29.5]
|
||||
|
||||
### Security
|
||||
- Add sandbox attribute to message iframe for extra later of security (already protected via CSP headers)
|
||||
|
||||
### Feature
|
||||
- Add option to disable auto-VACUUMing of the SQLite database ([#661](https://github.com/axllent/mailpit/issues/661))
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
|
||||
|
||||
## [v1.29.4]
|
||||
|
||||
### Feature
|
||||
- Add filter functionality to message headers tab
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
|
||||
### Fix
|
||||
- Refactor webhook delay & rate limit logic to ignore endpoint response times & prevent hardcoded 1000 message limit when set to 0 ([#656](https://github.com/axllent/mailpit/issues/656))
|
||||
|
||||
|
||||
## [v1.29.3]
|
||||
|
||||
### Security
|
||||
- Enhance CORS origin handling to respect host:port distinctions
|
||||
- Limit proxy requests to 50MB to prevent OOM attacks
|
||||
- Enhance HTML sanitization in message view
|
||||
- Enhance HTML sanitization in screenshot generation
|
||||
- Escape ContentID in HTML replacement to prevent regex injection
|
||||
|
||||
### Chore
|
||||
- Use last release + git hash in Docker edge versions
|
||||
- Bump minimatch from 10.2.2 to 10.2.4
|
||||
- Refactor code with go fix
|
||||
- Switch to math/rand/v2
|
||||
- Refactor API send authentication logic
|
||||
- Refactor events websocket middleware
|
||||
- Set timeout for HTTP client in webhook Send function
|
||||
- Use local hostname for EHLO/HELO in SMTP communication
|
||||
- Simplify HTML decoding function in screenshot generation using DOMParser
|
||||
- Set margin & padding to HTML screenshot to prevent transparent top/left border
|
||||
- Replace localStorage retrieval with a dedicated function for default release addresses
|
||||
- Limit subject length to 100 characters in browser notifications
|
||||
- Improve transaction handling in pruneMessages and fix loop continuation in InitDB
|
||||
- Update Content-Disposition header to use inline display and escape filename
|
||||
- Refactor timezone handling in searchQueryBuilder
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
|
||||
### Fix
|
||||
- Update SQL query to use tenant when using is:tagged filter
|
||||
|
||||
|
||||
## [v1.29.2]
|
||||
|
||||
### Security
|
||||
|
||||
@@ -41,7 +41,8 @@ settings to determine the HTTP bind interface & port.
|
||||
IdleConnTimeout: time.Second * 5,
|
||||
ExpectContinueTimeout: time.Second * 5,
|
||||
TLSHandshakeTimeout: time.Second * 5,
|
||||
// do not verify TLS in case this instance is using HTTPS
|
||||
// do not verify TLS if this instance is using HTTPS as we connect using IP
|
||||
// so won't be the same as the cert
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
|
||||
}
|
||||
client := &http.Client{Transport: conf}
|
||||
|
||||
@@ -86,6 +86,7 @@ func init() {
|
||||
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
|
||||
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
|
||||
rootCmd.Flags().BoolVar(&config.DisableAutoVACUUM, "disable-auto-vacuum", config.DisableAutoVACUUM, "Disable auto-VACUUM for the database")
|
||||
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
|
||||
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
@@ -202,6 +203,8 @@ func initConfigFromEnv() {
|
||||
|
||||
config.DisableVersionCheck = getEnabledFromEnv("MP_DISABLE_VERSION_CHECK")
|
||||
|
||||
config.DisableAutoVACUUM = getEnabledFromEnv("MP_DISABLE_AUTO_VACUUM")
|
||||
|
||||
if len(os.Getenv("MP_COMPRESSION")) > 0 {
|
||||
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ var (
|
||||
// @see https://sqlite.org/wal.html
|
||||
DisableWAL bool
|
||||
|
||||
// DisableAutoVACUUM will disable the auto-VACUUM of the local SQLite database when messages
|
||||
// are deleted and a preconfigured threshold is reached.
|
||||
DisableAutoVACUUM bool
|
||||
|
||||
// Compression is the compression level used to store raw messages in the database:
|
||||
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
|
||||
Compression = 1
|
||||
|
||||
@@ -94,9 +94,9 @@ func parseTagsDisable(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.ToLower(s), ",")
|
||||
parts := strings.SplitSeq(strings.ToLower(s), ",")
|
||||
|
||||
for _, p := range parts {
|
||||
for p := range parts {
|
||||
switch strings.TrimSpace(p) {
|
||||
case "x-tags", "xtags":
|
||||
TagsDisableXTags = true
|
||||
|
||||
@@ -26,8 +26,8 @@ func parseMaxAge() error {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if before, ok := strings.CutSuffix(MaxAge, "h"); ok {
|
||||
hours, err := strconv.Atoi(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,8 +221,8 @@ func validateForwardConfig() error {
|
||||
}
|
||||
|
||||
to := []string{}
|
||||
addresses := strings.Split(SMTPForwardConfig.To, ",")
|
||||
for _, a := range addresses {
|
||||
addresses := strings.SplitSeq(SMTPForwardConfig.To, ",")
|
||||
for a := range addresses {
|
||||
a = strings.TrimSpace(a)
|
||||
m, err := mail.ParseAddress(a)
|
||||
if err != nil {
|
||||
@@ -263,8 +263,8 @@ func parseChaosTriggers() error {
|
||||
|
||||
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
|
||||
|
||||
parts := strings.Split(ChaosTriggers, ",")
|
||||
for _, p := range parts {
|
||||
parts := strings.SplitSeq(ChaosTriggers, ",")
|
||||
for p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if !re.MatchString(p) {
|
||||
return fmt.Errorf("invalid argument: %s", p)
|
||||
|
||||
42
go.mod
42
go.mod
@@ -3,16 +3,16 @@ module github.com/axllent/mailpit
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/ghru/v2 v2.1.0
|
||||
github.com/axllent/ghru/v2 v2.2.0
|
||||
github.com/axllent/semver v1.0.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
||||
@@ -24,12 +24,12 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.31.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
github.com/vanng822/go-premailer v1.33.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/time v0.15.0
|
||||
modernc.org/sqlite v1.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -41,8 +41,8 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
@@ -50,32 +50,30 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.7 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.3 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.4 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
103
go.sum
103
go.sum
@@ -1,13 +1,13 @@
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/axllent/ghru/v2 v2.1.0 h1:zNW96KO+rmXggizZhHzIX7MExOiV4jx+63Y9nXlwLV0=
|
||||
github.com/axllent/ghru/v2 v2.1.0/go.mod h1:8l7s1phdc375vvf8LHxT7wnJqXlThdHJR5EBtHNWhTg=
|
||||
github.com/axllent/ghru/v2 v2.2.0 h1:DzWyWPJL+3qSwvR2S4tTetOhVgP9XjJixng1Aax8GGo=
|
||||
github.com/axllent/ghru/v2 v2.2.0/go.mod h1:tyH60pqmLCDHd3UMOZyiedrYMFVLwBQqPQ5y8WLvDzA=
|
||||
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
|
||||
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -21,18 +21,17 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
@@ -60,18 +59,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
@@ -83,8 +78,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
|
||||
@@ -97,10 +92,10 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=
|
||||
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
|
||||
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
|
||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -111,15 +106,13 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -150,13 +143,13 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.31.0 h1:r1a1WH2I5NnGMhrmjVZyYhY0ThvaamKBkS2UuM91Fuo=
|
||||
github.com/vanng822/go-premailer v1.31.0/go.mod h1:hzI26/YvzUADrxqifxGLJvNvn3tWBU6VMHRvxsskpuo=
|
||||
github.com/vanng822/go-premailer v1.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic=
|
||||
github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -164,19 +157,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -186,8 +177,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -195,8 +186,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -209,8 +200,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -229,10 +220,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -245,25 +236,23 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -272,8 +261,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
56
install.sh
56
install.sh
@@ -2,10 +2,39 @@
|
||||
|
||||
# This script will install the latest release of Mailpit.
|
||||
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
Mailpit install script
|
||||
|
||||
Usage:
|
||||
$(basename "$0") [OPTIONS]
|
||||
|
||||
Options:
|
||||
-h, --help Show this help and exit
|
||||
--install-path <path> Install location (default: /usr/local/bin)
|
||||
--auth, --auth-token,
|
||||
--github-token, --token <token> GitHub token for API authentication
|
||||
|
||||
Environment:
|
||||
INSTALL_PATH Default install path override
|
||||
GITHUB_TOKEN GitHub API token
|
||||
EOF
|
||||
}
|
||||
|
||||
# Show help if requested
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check dependencies is installed
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "Then $cmd command is required but not installed."
|
||||
echo "The $cmd command is required but not installed."
|
||||
echo "Please install $cmd and try again."
|
||||
exit 1
|
||||
fi
|
||||
@@ -17,7 +46,7 @@ case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*)
|
||||
echo "OS not supported."
|
||||
echo "Unsupported operating system: $(uname -s)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -35,7 +64,7 @@ aarch64 | arm64)
|
||||
OS_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "OS architecture not supported."
|
||||
echo "Unsupported architecture: $(uname -m)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -47,7 +76,7 @@ TIMEOUT=90
|
||||
# Try the GITHUB_TOKEN environment variable is set globally.
|
||||
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
|
||||
|
||||
# Update the default values if the user has set.
|
||||
# Override defaults with any user-supplied arguments.
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--install-path)
|
||||
@@ -66,6 +95,10 @@ while [ $# -gt 0 ]; do
|
||||
gh*)
|
||||
GITHUB_API_TOKEN="$1"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Invalid GitHub token. Token must start with \"gh\"."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*) ;;
|
||||
@@ -106,12 +139,19 @@ fi
|
||||
case "$VERSION" in
|
||||
v[0-9][0-9\.]*) ;;
|
||||
*)
|
||||
echo "There was an error trying to check what is the latest version of Mailpit."
|
||||
echo "Unable to determine the latest version of Mailpit."
|
||||
echo "Please try again later."
|
||||
if [ -z "$GITHUB_API_TOKEN" ]; then
|
||||
echo "Tip: Set GITHUB_TOKEN to authenticate and avoid GitHub API rate limiting."
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
;;
|
||||
esac
|
||||
|
||||
TEMP_DIR=""
|
||||
cleanup() { [ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
TEMP_DIR="$(mktemp -qd)"
|
||||
EXIT_CODE=$?
|
||||
# Ensure the temporary directory exists and is a directory.
|
||||
@@ -198,17 +238,15 @@ if [ $EXIT_CODE -eq 0 ]; then
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Changing to temporary directory."
|
||||
echo "ERROR: Could not change to temporary directory."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
# Cleanup the temporary directory.
|
||||
rm -rf "$TEMP_DIR"
|
||||
# Check the EXIT_CODE variable, and print the success or error message.
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "There was an error installing Mailpit."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
echo "Installed successfully to \"$INSTALL_BIN_PATH\"."
|
||||
echo "Mailpit ${VERSION} installed successfully to \"$INSTALL_BIN_PATH\"."
|
||||
exit 0
|
||||
|
||||
@@ -42,19 +42,19 @@ type CanIEmail struct {
|
||||
|
||||
// JSONResult struct for CanIEmail Data
|
||||
type JSONResult struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]any `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
}
|
||||
|
||||
// Load the JSON data
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestInlineStyleDetection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
func assertEqual(t *testing.T, a any, b any, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
for platform, clients := range stats.(map[string]any) {
|
||||
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
for version, support := range clients.(map[string]any) {
|
||||
s := Result{}
|
||||
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
|
||||
s.Family = family
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"slices"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
@@ -18,7 +18,7 @@ func Platforms() (map[string][]string, error) {
|
||||
for _, t := range cie.Data {
|
||||
for family, stats := range t.Stats {
|
||||
niceFamily := cie.NiceNames.Family[family]
|
||||
for platform := range stats.(map[string]interface{}) {
|
||||
for platform := range stats.(map[string]any) {
|
||||
c, found := data[platform]
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
@@ -32,9 +32,7 @@ func Platforms() (map[string][]string, error) {
|
||||
}
|
||||
|
||||
for group, clients := range data {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i] < clients[j]
|
||||
})
|
||||
slices.Sort(clients)
|
||||
data[group] = clients
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ func doHead(link string, followRedirects bool) (int, error) {
|
||||
}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
// user has explicitly allowed untrusted TLS, so we will not verify it for link checks
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func Log() *logrus.Logger {
|
||||
}
|
||||
|
||||
// PrettyPrint for debugging
|
||||
func PrettyPrint(i interface{}) {
|
||||
func PrettyPrint(i any) {
|
||||
s, _ := json.MarshalIndent(i, "", "\t")
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
|
||||
@@ -362,11 +362,11 @@ func randRange(min, max int) int {
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
for i := range 50 {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
@@ -397,7 +397,7 @@ func insertEmailData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
func assertEqual(t *testing.T, a any, b any, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func (c *Conn) Send(b string) error {
|
||||
// in case of single line responses, or a help message followed by multiple lines of actual response
|
||||
// data in case of multiline responses.
|
||||
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
|
||||
func (c *Conn) Cmd(cmd string, isMulti bool, args ...interface{}) (*bytes.Buffer, error) {
|
||||
func (c *Conn) Cmd(cmd string, isMulti bool, args ...any) (*bytes.Buffer, error) {
|
||||
var cmdLine string
|
||||
|
||||
// Repeat a %v to format each arg.
|
||||
@@ -441,12 +441,12 @@ func parseResp(b []byte) ([]byte, error) {
|
||||
|
||||
if bytes.Equal(b, respOK) {
|
||||
return nil, nil
|
||||
} else if bytes.HasPrefix(b, respOKInfo) {
|
||||
return bytes.TrimPrefix(b, respOKInfo), nil
|
||||
} else if after, ok := bytes.CutPrefix(b, respOKInfo); ok {
|
||||
return after, nil
|
||||
} else if bytes.Equal(b, respErr) {
|
||||
return nil, errors.New("unknown error (no info specified in response)")
|
||||
} else if bytes.HasPrefix(b, respErrInfo) {
|
||||
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
|
||||
} else if after, ok := bytes.CutPrefix(b, respErrInfo); ok {
|
||||
return nil, errors.New(string(after))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
|
||||
|
||||
@@ -108,9 +108,9 @@ func forward(from string, msg []byte) error {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
to := strings.Split(config.SMTPForwardConfig.To, ",")
|
||||
to := strings.SplitSeq(config.SMTPForwardConfig.To, ",")
|
||||
|
||||
for _, addr := range to {
|
||||
for addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
if config.SMTPForwardConfig.ForwardSMTPErrors {
|
||||
|
||||
@@ -337,7 +337,7 @@ func (srv *Server) Shutdown(ctx context.Context) error {
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
for i := 0; i < 300; i++ {
|
||||
for range 300 {
|
||||
// wait for open sessions to close
|
||||
if atomic.LoadInt32(&srv.openSessions) == 0 {
|
||||
break
|
||||
@@ -636,8 +636,8 @@ loop:
|
||||
case "XCLIENT":
|
||||
s.xClient = args
|
||||
if s.xClientTrust {
|
||||
xCArgs := strings.Split(args, " ")
|
||||
for _, xCArg := range xCArgs {
|
||||
xCArgs := strings.SplitSeq(args, " ")
|
||||
for xCArg := range xCArgs {
|
||||
xCParse := strings.Split(strings.TrimSpace(xCArg), "=")
|
||||
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
|
||||
s.xClientADDR = xCParse[1]
|
||||
@@ -786,7 +786,7 @@ loop:
|
||||
}
|
||||
|
||||
// Wrapper function for writing a complete line to the socket.
|
||||
func (s *session) writef(format string, args ...interface{}) {
|
||||
func (s *session) writef(format string, args ...any) {
|
||||
if s.srv.Timeout > 0 {
|
||||
_ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
@@ -831,9 +831,9 @@ func (s *session) readLine() (string, error) {
|
||||
|
||||
// Parse a line read from the socket.
|
||||
func (s *session) parseLine(line string) (verb string, args string) {
|
||||
if idx := strings.Index(line, " "); idx != -1 {
|
||||
verb = strings.ToUpper(line[:idx])
|
||||
args = strings.TrimSpace(line[idx+1:])
|
||||
if before, after, ok := strings.Cut(line, " "); ok {
|
||||
verb = strings.ToUpper(before)
|
||||
args = strings.TrimSpace(after)
|
||||
} else {
|
||||
verb = strings.ToUpper(line)
|
||||
args = ""
|
||||
|
||||
@@ -779,8 +779,8 @@ func parseExtensions(t *testing.T, greeting string) map[string]string {
|
||||
|
||||
// Add line as extension.
|
||||
line = strings.TrimSpace(line[4:]) // Strip code prefix and trailing \r\n
|
||||
if idx := strings.Index(line, " "); idx != -1 {
|
||||
extensions[line[:idx]] = line[idx+1:]
|
||||
if before, after, ok := strings.Cut(line, " "); ok {
|
||||
extensions[before] = after
|
||||
} else {
|
||||
extensions[line] = ""
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
|
||||
// Database cron runs every minute
|
||||
func dbCron() {
|
||||
if config.DisableAutoVACUUM {
|
||||
if sqlDriver == "rqlite" {
|
||||
logger.Log().Warn("[db] disable-auto-vacuum has no effect as rqlite handles vacuuming automatically")
|
||||
} else {
|
||||
logger.Log().Infof("[db] auto-VACUUM is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
|
||||
@@ -35,7 +43,7 @@ func dbCron() {
|
||||
deletedPercent = float64(deletedSize * 100 / total)
|
||||
}
|
||||
// only vacuum the DB if at least 1% of mail storage size has been deleted
|
||||
if deletedPercent >= 1 {
|
||||
if !config.DisableAutoVACUUM && deletedPercent >= 1 {
|
||||
logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
|
||||
vacuumDb()
|
||||
}
|
||||
@@ -128,7 +136,10 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
// roll back if it fails
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
@@ -151,13 +162,8 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err != nil {
|
||||
if err = tx.Commit(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
if err := tx.Rollback(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
|
||||
@@ -110,7 +110,7 @@ func InitDB() error {
|
||||
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
|
||||
time.Sleep(5 * time.Second)
|
||||
} else {
|
||||
continue
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func setup(tenantID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
func assertEqual(t *testing.T, a any, b any, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ func DeleteMessages(ids []string) error {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
@@ -696,7 +696,7 @@ func DeleteMessages(ids []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
args = make([]interface{}, len(toDelete))
|
||||
args = make([]any, len(toDelete))
|
||||
for i, id := range toDelete {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
for range testRuns {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -54,7 +54,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
for range testRuns {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -80,10 +80,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
nrResults = len(allResults)
|
||||
|
||||
if nrResults > start {
|
||||
end := nrResults
|
||||
if nrResults >= start+limit {
|
||||
end = start + limit
|
||||
}
|
||||
end := min(nrResults, start+limit)
|
||||
|
||||
results = allResults[start:end]
|
||||
}
|
||||
@@ -196,7 +193,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, ids := range chunks {
|
||||
delIDs := make([]interface{}, len(ids))
|
||||
delIDs := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
delIDs[i] = id
|
||||
}
|
||||
@@ -303,12 +300,12 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
args := tools.ArgsParser(searchString)
|
||||
|
||||
loc := time.Local
|
||||
if timezone != "" {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
if l, err := time.LoadLocation(timezone); err != nil {
|
||||
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
|
||||
} else {
|
||||
time.Local = loc
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,9 +437,9 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
}
|
||||
} else if lw == "is:tagged" {
|
||||
if exclude {
|
||||
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
|
||||
}
|
||||
} else if lw == "has:inline" || lw == "has:inlines" {
|
||||
if exclude {
|
||||
@@ -459,7 +456,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
} else if strings.HasPrefix(lw, "after:") {
|
||||
w = cleanString(w[6:])
|
||||
if w != "" {
|
||||
t, err := dateparse.ParseLocal(w)
|
||||
t, err := dateparse.ParseIn(w, loc)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
|
||||
} else {
|
||||
@@ -474,7 +471,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
} else if strings.HasPrefix(lw, "before:") {
|
||||
w = cleanString(w[7:])
|
||||
if w != "" {
|
||||
t, err := dateparse.ParseLocal(w)
|
||||
t, err := dateparse.ParseIn(w, loc)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
@@ -22,13 +22,13 @@ func TestSearch(t *testing.T) {
|
||||
t.Logf("Testing search (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
for i := range testRuns {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)).
|
||||
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
|
||||
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
|
||||
@@ -73,7 +73,7 @@ func TestSearch(t *testing.T) {
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
searchIdx := rand.IntN(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestSearchDelete100(t *testing.T) {
|
||||
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -157,7 +157,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
for range 1100 {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -25,7 +26,7 @@ func TestTags(t *testing.T) {
|
||||
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
@@ -34,14 +35,14 @@ func TestTags(t *testing.T) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := range 10 {
|
||||
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := range 10 {
|
||||
message, err := GetMessage(ids[i])
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
@@ -65,7 +66,7 @@ func TestTags(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
for i := range 20 {
|
||||
// pad number with 0 to ensure they are returned alphabetically
|
||||
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
|
||||
}
|
||||
@@ -159,13 +160,7 @@ func TestUsernameAutoTagging(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(msg.Tags, username)
|
||||
if !found {
|
||||
t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags)
|
||||
}
|
||||
|
||||
1180
package-lock.json
generated
1180
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,13 @@ func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
if err = c.Hello(addr); err != nil {
|
||||
// Use the local hostname for EHLO/HELO as required by RFC 5321.
|
||||
// Fall back to "localhost" if the hostname cannot be determined.
|
||||
localHostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
localHostname = "localhost"
|
||||
}
|
||||
if err = c.Hello(localHostname); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -158,7 +159,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+url.PathEscape(fileName)+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,17 +48,19 @@ func corsOriginAccessControl(r *http.Request) bool {
|
||||
}
|
||||
|
||||
_, allAllowed := corsAllowOrigins["*"]
|
||||
// allow same origin || is "*" is defined as an origin
|
||||
// allow same origin, or if "*" is defined as an origin
|
||||
if asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed {
|
||||
return true
|
||||
}
|
||||
|
||||
originHostFold := asciiFoldString(u.Hostname())
|
||||
// match on full host:port so that example.com:8080 is not admitted
|
||||
// by an allowlist entry for example.com (standard port 80/443).
|
||||
originHostFold := asciiFoldString(u.Host)
|
||||
if corsAllowOrigins[originHostFold] {
|
||||
return true
|
||||
}
|
||||
|
||||
logger.Log().Warnf("[cors] blocking request from unauthorized origin: %s", u.Hostname())
|
||||
logger.Log().Warnf("[cors] blocking request from unauthorized origin: %s", u.Host)
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -67,7 +69,8 @@ func corsOriginAccessControl(r *http.Request) bool {
|
||||
}
|
||||
|
||||
// SetCORSOrigins sets the allowed CORS origins from a comma-separated string.
|
||||
// It does not consider port or protocol, only the hostname.
|
||||
// Origins are matched on the full host:port, so example.com and example.com:8080
|
||||
// are treated as distinct origins.
|
||||
func setCORSOrigins() {
|
||||
corsAllowOrigins = make(map[string]bool)
|
||||
|
||||
@@ -120,7 +123,9 @@ func extractOrigins(str string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
origins = append(origins, u.Hostname())
|
||||
// Store host:port so port differences are respected.
|
||||
// u.Host equals u.Hostname() when no port is present.
|
||||
origins = append(origins, u.Host)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestExtractOrigins(t *testing.T) {
|
||||
{
|
||||
name: "mixed protocols",
|
||||
input: "http://example.com,https://foo.com:8080",
|
||||
expected: []string{"example.com", "foo.com"},
|
||||
expected: []string{"example.com", "foo.com:8080"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -78,7 +78,10 @@ func TestCorsOriginAccessControl(t *testing.T) {
|
||||
allow bool
|
||||
}{
|
||||
{"no origin header", "", "example.com", true},
|
||||
{"allowed origin", "http://example.com:1234", "mailpit.local", true},
|
||||
// example.com:1234 must NOT be admitted by an allowlist entry for example.com (different port)
|
||||
{"allowed origin", "http://example.com:1234", "mailpit.local", false},
|
||||
{"allowed origin", "http://example.com:1234", "example.com", false},
|
||||
{"allowed origin", "http://example.com:1234", "example.com:1234", true},
|
||||
{"not allowed origin", "http://notallowed.com", "mailpit.local", false},
|
||||
{"allowed by hostname", "http://foo.com", "mailpit.local", true},
|
||||
{"ascii fold: allowed origin uppercase", "HTTP://EXAMPLE.COM", "mailpit.local", true},
|
||||
|
||||
@@ -23,6 +23,12 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxProxyBodySize is the maximum number of bytes read from a proxied
|
||||
// response body (fonts, images, CSS). Prevents OOM on oversized responses.
|
||||
maxProxyBodySize = 50 * 1024 * 1024 // 50 MB
|
||||
)
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
@@ -164,12 +170,18 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
limitedBody := io.LimitReader(resp.Body, maxProxyBodySize+1)
|
||||
body, err := io.ReadAll(limitedBody)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, "Error: invalid request")
|
||||
return
|
||||
}
|
||||
if int64(len(body)) > maxProxyBodySize {
|
||||
logger.Log().Warnf("[proxy] response body for %s exceeds %d bytes, blocking", uri, maxProxyBodySize)
|
||||
httpError(w, "Error: response too large")
|
||||
return
|
||||
}
|
||||
|
||||
// relay common headers
|
||||
w.Header().Set("content-type", ct)
|
||||
|
||||
@@ -4,6 +4,7 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -36,6 +37,13 @@ var (
|
||||
htmlPreviewRouteRe *regexp.Regexp
|
||||
)
|
||||
|
||||
// skipUIAuthKey is a private context key used to signal that UI basic-auth
|
||||
// should be bypassed for a specific request. This avoids mutating the global
|
||||
// auth.UICredentials pointer (which is a data race under concurrent load).
|
||||
type contextKey int
|
||||
|
||||
const skipUIAuthKey contextKey = iota
|
||||
|
||||
// Listen will start the httpd
|
||||
func Listen() {
|
||||
setCORSOrigins()
|
||||
@@ -198,7 +206,7 @@ func apiRoutes() *mux.Router {
|
||||
}
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/events", middleWareFunc(apiWebsocket)).Methods("GET")
|
||||
|
||||
// return blank 200 response for OPTIONS requests for CORS
|
||||
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
||||
@@ -213,22 +221,20 @@ func basicAuthResponse(w http.ResponseWriter) {
|
||||
_, _ = w.Write([]byte("Unauthorized.\n"))
|
||||
}
|
||||
|
||||
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
|
||||
// It can use dedicated send API authentication or accept any credentials based on configuration
|
||||
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint.
|
||||
// It can use dedicated send API authentication or accept any credentials based on configuration.
|
||||
// It communicates skip-UI-auth intent via request context rather than mutating the global
|
||||
// auth.UICredentials pointer, which would be a data race under concurrent load.
|
||||
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// If send API auth accept any is enabled, bypass all authentication
|
||||
// If send API auth accept any is enabled, bypass all authentication.
|
||||
if config.SendAPIAuthAcceptAny {
|
||||
// Temporarily disable UI auth for this request
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
// Call the standard middleware
|
||||
middleWareFunc(fn)(w, r)
|
||||
ctx := context.WithValue(r.Context(), skipUIAuthKey, true)
|
||||
middleWareFunc(fn)(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// If Send API credentials are configured, only accept those credentials
|
||||
// If Send API credentials are configured, only accept those credentials.
|
||||
if auth.SendAPICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
@@ -242,15 +248,13 @@ func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Valid Send API credentials - bypass UI auth and call function directly
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
middleWareFunc(fn)(w, r)
|
||||
// Valid Send API credentials — bypass UI auth via context flag.
|
||||
ctx := context.WithValue(r.Context(), skipUIAuthKey, true)
|
||||
middleWareFunc(fn)(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// No Send API credentials configured - fall back to UI auth
|
||||
// No Send API credentials configured — fall back to UI auth.
|
||||
middleWareFunc(fn)(w, r)
|
||||
}
|
||||
}
|
||||
@@ -291,7 +295,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
if strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI) {
|
||||
if allowed := corsOriginAccessControl(r); !allowed {
|
||||
http.Error(w, "Blocked to to CORS violation", http.StatusForbidden)
|
||||
http.Error(w, "Blocked due to CORS violation", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
@@ -301,8 +305,11 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
// Check basic authentication headers if configured.
|
||||
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight checks.
|
||||
// skipUIAuthKey in the request context allows sendAPIAuthMiddleware to bypass UI auth
|
||||
// for a specific request without touching the global auth.UICredentials pointer.
|
||||
skipUIAuth, _ := r.Context().Value(skipUIAuthKey).(bool)
|
||||
isCORSOptionsRequest := AccessControlAllowOrigin != "" && r.Method == http.MethodOptions
|
||||
if !isCORSOptionsRequest && auth.UICredentials != nil {
|
||||
if !skipUIAuth && !isCORSOptionsRequest && auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -316,7 +323,11 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
// WebSocket upgrade requests must not be wrapped in a gzip writer:
|
||||
// gzipResponseWriter does not implement http.Hijacker, which the
|
||||
// WebSocket library requires to take over the raw TCP connection.
|
||||
isWebSocketUpgrade := strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
|
||||
if isWebSocketUpgrade || config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
fn(w, r)
|
||||
return
|
||||
}
|
||||
@@ -334,14 +345,9 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
// Websocket to broadcast changes.
|
||||
// Authentication and CORS are handled by middleWareFunc before this is reached.
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
if allowed := corsOriginAccessControl(r); !allowed {
|
||||
http.Error(w, "Blocked to to CORS violation", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
storage.BroadcastMailboxStats()
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ var (
|
||||
}
|
||||
|
||||
// Shared test message structure for consistency
|
||||
testSendMessage = map[string]interface{}{
|
||||
testSendMessage = map[string]any{
|
||||
"From": map[string]string{
|
||||
"Email": "test@example.com",
|
||||
},
|
||||
@@ -545,11 +545,11 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
@@ -754,7 +754,7 @@ func clientGetWithAuth(url, username, password string) ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
func assertEqual(t *testing.T, a any, b any, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isEdgeBuild() {
|
||||
const re = /^(v\d+.\d+.\d+-)/i;
|
||||
return re.test(mailbox.appInfo.Version);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo() {
|
||||
this.get(this.resolve("/api/v1/info"), false, (response) => {
|
||||
@@ -98,6 +105,7 @@ export default {
|
||||
<h5 id="AppInfoModalLabel" class="modal-title">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
<span v-if="isEdgeBuild" class="badge bg-info text-dark ms-2">edge build</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,8 @@ export default {
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true;
|
||||
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||
const subject = String(response.Data.Subject ?? "").substring(0, 100);
|
||||
this.browserNotify("New mail from: " + from, subject);
|
||||
this.setMessageToast(response.Data);
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -14,9 +14,7 @@ export default {
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
defaultReleaseAddressesOptions: localStorage.getItem("defaultReleaseAddresses")
|
||||
? JSON.parse(localStorage.getItem("defaultReleaseAddresses"))
|
||||
: [], // set with default release addresses
|
||||
defaultReleaseAddressesOptions: mailbox.defaultReleaseAddresses.slice(), // set with default release addresses
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -14,27 +14,89 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
headers: false,
|
||||
filter: "",
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredHeaders() {
|
||||
if (this.filter === "") {
|
||||
return this.headers;
|
||||
}
|
||||
const searchWords = this.filter
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((x) => x.length > 0);
|
||||
|
||||
const filtered = {};
|
||||
for (const k in this.headers) {
|
||||
const values = this.headers[k];
|
||||
const kLower = k.toLowerCase();
|
||||
if (searchWords.every((w) => kLower.includes(w))) {
|
||||
filtered[k] = values;
|
||||
} else {
|
||||
const matchingValues = values.filter((v) => {
|
||||
const vLower = v.toLowerCase();
|
||||
return searchWords.every((w) => vLower.includes(w));
|
||||
});
|
||||
if (matchingValues.length > 0) {
|
||||
filtered[k] = matchingValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data;
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
highlight(text) {
|
||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
if (!this.filter || this.filter.trim() === "") {
|
||||
return escaped;
|
||||
}
|
||||
const words = this.filter
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0)
|
||||
.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const regex = new RegExp(words.join("|"), "gi");
|
||||
return escaped.replace(regex, "<mark>$&</mark>");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<template v-if="headers">
|
||||
<div class="row w-100 mb-3">
|
||||
<div class="col col-md-10 col-lg-7">
|
||||
<input
|
||||
v-model.trim="filter"
|
||||
type="search"
|
||||
class="form-control mb-3"
|
||||
placeholder="Filter headers..."
|
||||
aria-label="Filter headers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Object.keys(filteredHeaders).length > 0" class="small">
|
||||
<div v-for="(values, k) in filteredHeaders" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
|
||||
<b>{{ k }}</b>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<b v-html="highlight(k)"></b>
|
||||
</div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break" v-html="highlight(x)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-body-secondary">No matching headers found.</div>
|
||||
</template>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default {
|
||||
"vspace",
|
||||
"xml:lang",
|
||||
],
|
||||
FORBID_ATTR: ["script"], // all JavaScript should be removed
|
||||
FORBID_TAGS: ["script", "form"], // all JavaScript should be removed
|
||||
ALLOW_UNKNOWN_PROTOCOLS: true, // allow link href protocols like myapp:// etc
|
||||
});
|
||||
|
||||
@@ -288,9 +288,12 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// Convert plain text to HTML including anchor links
|
||||
// Convert plain text to HTML including anchor links.
|
||||
// Only <a> tags are permitted in the output (enforced by DOMPurify).
|
||||
textToHTML(s) {
|
||||
let html = s;
|
||||
// Strip the Unicode placeholder characters used below so that attacker-
|
||||
// controlled input cannot pre-inject fake HTML tags via those chars.
|
||||
let html = s.replace(/(˱˱˱|ˠˠˠ|˲˲˲)/gu, "");
|
||||
|
||||
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
|
||||
// recognize potential spaces in between the URL
|
||||
@@ -320,7 +323,10 @@ export default {
|
||||
.replace(/˲˲˲/g, ">")
|
||||
.replace(/ˠˠˠ/g, '"');
|
||||
|
||||
return html;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ["a"],
|
||||
ALLOWED_ATTR: ["href", "target", "rel"],
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -779,6 +785,7 @@ export default {
|
||||
:srcdoc="sanitizedHTML"
|
||||
frameborder="0"
|
||||
style="width: 100%; height: 100%; background: #fff"
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
@load="resizeIframe"
|
||||
>
|
||||
</iframe>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AjaxLoader from "../AjaxLoader.vue";
|
||||
import CommonMixins from "../../mixins/CommonMixins";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -41,18 +42,38 @@ export default {
|
||||
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
|
||||
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
|
||||
|
||||
// Sanitize HTML before writing to the temporary document.
|
||||
// This removes <script>, <noscript>, inline event handlers (on*),
|
||||
// SVG <animate>/<set> with xlink:href and other active content
|
||||
// that manual tag removal would miss.
|
||||
h = DOMPurify.sanitize(h, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
FORCE_BODY: false,
|
||||
ADD_TAGS: ["link", "meta", "o:p", "style"],
|
||||
ADD_ATTR: [
|
||||
"bordercolor",
|
||||
"charset",
|
||||
"content",
|
||||
"hspace",
|
||||
"http-equiv",
|
||||
"itemprop",
|
||||
"itemscope",
|
||||
"itemtype",
|
||||
"vertical-align",
|
||||
"vlink",
|
||||
"vspace",
|
||||
"xml:lang",
|
||||
"background", // needed for background= URL replacement below
|
||||
],
|
||||
FORBID_TAGS: ["script", "noscript"],
|
||||
});
|
||||
|
||||
// create temporary document to manipulate
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.open();
|
||||
doc.writeln(h);
|
||||
doc.close();
|
||||
|
||||
// remove any <script> tags
|
||||
const scripts = doc.getElementsByTagName("script");
|
||||
for (const i of scripts) {
|
||||
i.parentNode.removeChild(i);
|
||||
}
|
||||
|
||||
// replace any url(...) links in <style> blocks
|
||||
const styles = doc.getElementsByTagName("style");
|
||||
for (const i of styles) {
|
||||
@@ -117,11 +138,7 @@ export default {
|
||||
|
||||
// HTML decode function
|
||||
decodeEntities(s) {
|
||||
const e = document.createElement("div");
|
||||
e.innerHTML = s;
|
||||
const str = e.textContent;
|
||||
e.textContent = "";
|
||||
return str;
|
||||
return new DOMParser().parseFromString(s, "text/html").body.textContent;
|
||||
},
|
||||
|
||||
doScreenshot() {
|
||||
@@ -143,11 +160,18 @@ export default {
|
||||
|
||||
const body = i.contentWindow.document.querySelector("body");
|
||||
|
||||
// Add body padding to prevent content touching edge of screenshot.
|
||||
body.style.padding = "20px";
|
||||
|
||||
// take screenshot of iframe
|
||||
domToPng(body, {
|
||||
backgroundColor: "#ffffff",
|
||||
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||
height: i.contentWindow.document.body.scrollHeight,
|
||||
width,
|
||||
// remove the transparent 8px top and left gap from html object (default browser margins).
|
||||
style: {
|
||||
margin: "0",
|
||||
},
|
||||
}).then((dataUrl) => {
|
||||
const link = document.createElement("a");
|
||||
link.download = this.message.ID + ".png";
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
import { reactive, watch } from "vue";
|
||||
|
||||
// Parse and validate a string[] from localStorage, returning [] on any invalid value.
|
||||
const storageToStringArray = (key) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// global mailbox info
|
||||
export const mailbox = reactive({
|
||||
total: 0, // total number of messages in database
|
||||
@@ -20,9 +35,7 @@ export const mailbox = reactive({
|
||||
appInfo: {}, // application information
|
||||
uiConfig: {}, // configuration for UI
|
||||
lastMessage: false, // return scrolling
|
||||
defaultReleaseAddresses: localStorage.getItem("defaultReleaseAddresses")
|
||||
? JSON.parse(localStorage.getItem("defaultReleaseAddresses"))
|
||||
: [], // default release addresses for released messages
|
||||
defaultReleaseAddresses: storageToStringArray("defaultReleaseAddresses"), // default release addresses for released messages
|
||||
|
||||
// settings
|
||||
showTagColors: !localStorage.getItem("hideTagColors"),
|
||||
|
||||
@@ -151,8 +151,9 @@ export default {
|
||||
for (const i in d.Inline) {
|
||||
const a = d.Inline[i];
|
||||
if (a.ContentID !== "") {
|
||||
const escapedCID = a.ContentID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
new RegExp("(=[\"']?)(cid:" + escapedCID + ")([\"'|\\s|\\/|>|;])", "g"),
|
||||
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||
);
|
||||
}
|
||||
@@ -171,8 +172,9 @@ export default {
|
||||
for (const i in d.Attachments) {
|
||||
const a = d.Attachments[i];
|
||||
if (a.ContentID !== "") {
|
||||
const escapedCID = a.ContentID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
|
||||
new RegExp("(=[\"']?)(cid:" + escapedCID + ")([\"'|\\s|\\/|>|;])", "g"),
|
||||
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
@@ -13,16 +14,18 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// RateLimit is the minimum number of seconds between requests
|
||||
// RateLimit is the minimum number of seconds between requests.
|
||||
// Additional requests within this period will be ignored until
|
||||
// the time has elapsed.
|
||||
RateLimit = 1
|
||||
|
||||
// Delay is the number of seconds to wait before sending each webhook request
|
||||
// This can allow for other processing to complete before the webhook is triggered.
|
||||
// This can allow for other processing to complete before the webhook is triggered.
|
||||
Delay = 0
|
||||
|
||||
rl rate.Sometimes
|
||||
|
||||
rateLimiterSet bool
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Send will post the MessageSummary to a webhook (if configured)
|
||||
@@ -31,23 +34,22 @@ func Send(msg any) {
|
||||
return
|
||||
}
|
||||
|
||||
if !rateLimiterSet {
|
||||
once.Do(func() {
|
||||
if RateLimit > 0 {
|
||||
rl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second}
|
||||
} else {
|
||||
// run 1000 per second - ie: do not limit
|
||||
rl = rate.Sometimes{First: 1000, Interval: time.Second}
|
||||
// allow every request
|
||||
rl = rate.Sometimes{Every: 1}
|
||||
}
|
||||
rateLimiterSet = true
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Apply delay if configured
|
||||
if Delay > 0 {
|
||||
time.Sleep(time.Duration(Delay) * time.Second)
|
||||
}
|
||||
rl.Do(func() {
|
||||
go func() {
|
||||
// apply delay if configured
|
||||
if Delay > 0 {
|
||||
time.Sleep(time.Duration(Delay) * time.Second)
|
||||
}
|
||||
|
||||
rl.Do(func() {
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[webhook] invalid data: %s", err.Error())
|
||||
@@ -67,19 +69,18 @@ func Send(msg any) {
|
||||
req.Header.Set("Mailpit-Label", config.Label)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[webhook] error sending data: %s", err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
logger.Log().Warnf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
}()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (c *Client) writePump() {
|
||||
|
||||
// Add queued chat messages to the current websocket message.
|
||||
n := len(c.send)
|
||||
for i := 0; i < n; i++ {
|
||||
for range n {
|
||||
_, _ = w.Write(newline)
|
||||
_, _ = w.Write(<-c.send)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ type Hub struct {
|
||||
// WebsocketNotification struct for responses
|
||||
type WebsocketNotification struct {
|
||||
Type string
|
||||
Data interface{}
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewHub returns a new hub configuration
|
||||
@@ -69,7 +69,7 @@ func (h *Hub) Run() {
|
||||
}
|
||||
|
||||
// Broadcast will spawn a broadcast message to all connected clients
|
||||
func Broadcast(t string, msg interface{}) {
|
||||
func Broadcast(t string, msg any) {
|
||||
if MessageHub == nil || len(MessageHub.Clients) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user