Compare commits

...

44 Commits

Author SHA1 Message Date
Ralph Slooten
294faa4f10 Merge branch 'release/v1.29.5' 2026-03-29 17:47:11 +13:00
Ralph Slooten
25b9ebd90e Release v1.29.5 2026-03-29 17:47:10 +13:00
Ralph Slooten
87472746a9 Chore: Update node dependencies 2026-03-29 17:41:37 +13:00
Ralph Slooten
9dd1e99f52 Fixes for eslint validation 2026-03-29 17:40:43 +13:00
Ralph Slooten
fcca56625f Chore: Update Go dependencies 2026-03-29 17:38:59 +13:00
Ralph Slooten
3a4c7766e9 Feature: Add option to disable auto-VACUUMing of the SQLite database (#661) 2026-03-29 17:29:02 +13:00
Ralph Slooten
dc9b8d54b7 Security: Add sandbox attribute to message iframe for extra later of security (already protected via CSP headers)
Note that this does not provide any additional security as such as the CSP headers do this, however it is another barrier when it comes to bypass attempts.
2026-03-28 08:01:51 +13:00
Ralph Slooten
b8cc1bc415 Fix typo 2026-03-26 07:47:20 +13:00
Ralph Slooten
0fee30d3df Enhance install script: Improve help output, error messages, and user feedback 2026-03-22 18:35:48 +13:00
Ralph Slooten
1200ad0506 Merge tag 'v1.29.4' into develop
Release v1.29.4
2026-03-22 17:56:59 +13:00
Ralph Slooten
c12c6458a3 Merge branch 'release/v1.29.4' 2026-03-22 17:56:53 +13:00
Ralph Slooten
16f0c1416d Release v1.29.4 2026-03-22 17:56:53 +13:00
Ralph Slooten
0e3441aba9 Chore: Update node dependencies 2026-03-22 17:52:31 +13:00
Ralph Slooten
2dc2145db7 Chore: Update Go dependencies 2026-03-22 17:48:59 +13:00
Ralph Slooten
9c2359eee5 Feature: Add filter functionality to message headers tab
This implementation is based on, and resolves, #626
2026-03-22 17:40:54 +13:00
Ralph Slooten
7b22d6a5f9 Fix: Refactor webhook delay & rate limit logic to ignore endpoint response times & prevent hardcoded 1000 message limit when set to 0 (#656) 2026-03-16 22:29:45 +13:00
Ralph Slooten
fcd964501a Merge tag 'v1.29.3' into develop
Release v1.29.3
2026-03-10 15:29:51 +13:00
Ralph Slooten
3a222dd147 Merge branch 'release/v1.29.3' 2026-03-10 15:29:47 +13:00
Ralph Slooten
857cf78984 Release v1.29.3 2026-03-10 15:29:45 +13:00
Ralph Slooten
6802e24e55 Chore: Update node dependencies 2026-03-10 15:21:02 +13:00
Ralph Slooten
deaab34cdd Chore: Update Go dependencies 2026-03-10 15:18:59 +13:00
Ralph Slooten
ee9863289a Chore: Refactor timezone handling in searchQueryBuilder 2026-03-10 12:07:52 +13:00
Ralph Slooten
70037e96f4 Chore: Update Content-Disposition header to use inline display and escape filename 2026-03-10 12:03:35 +13:00
Ralph Slooten
fc0b016549 Chore: Improve transaction handling in pruneMessages and fix loop continuation in InitDB 2026-03-10 11:53:36 +13:00
Ralph Slooten
140633718c Chore: Limit subject length to 100 characters in browser notifications 2026-03-10 11:31:21 +13:00
Ralph Slooten
f40911c580 Security: Escape ContentID in HTML replacement to prevent regex injection 2026-03-10 11:27:47 +13:00
Ralph Slooten
3073ef9afe Chore: Replace localStorage retrieval with a dedicated function for default release addresses 2026-03-10 11:20:33 +13:00
Ralph Slooten
804d49b7ca Chore: Set margin & padding to HTML screenshot to prevent transparent top/left border 2026-03-10 11:09:28 +13:00
Ralph Slooten
7d29dff5e7 Security: Enhance HTML sanitization in screenshot generation 2026-03-10 10:24:40 +13:00
Ralph Slooten
bc8a737d4f Chore: Simplify HTML decoding function in screenshot generation using DOMParser 2026-03-10 10:04:47 +13:00
Ralph Slooten
b99be839a0 Security: Enhance HTML sanitization in message view 2026-03-10 10:02:10 +13:00
Ralph Slooten
c1db706677 Update inline TLS verification docs for healthcheck and link checks 2026-03-09 12:44:39 +13:00
Ralph Slooten
ab3fc5ead7 Chore: Use local hostname for EHLO/HELO in SMTP communication 2026-03-09 12:38:34 +13:00
Ralph Slooten
a72d42c8d4 Chore: Set timeout for HTTP client in webhook Send function 2026-03-09 12:34:50 +13:00
Ralph Slooten
f8052e1d56 Security: Limit proxy requests to 50MB to prevent OOM attacks 2026-03-09 12:31:17 +13:00
Ralph Slooten
267bf8b639 Security: Enhance CORS origin handling to respect host:port distinctions 2026-03-09 12:30:56 +13:00
Ralph Slooten
51e327f259 Fix: Update SQL query to use tenant when using is:tagged filter 2026-03-09 11:37:40 +13:00
Ralph Slooten
bb6bdf629d Chore: Refactor events websocket middleware 2026-03-09 11:20:45 +13:00
Ralph Slooten
a0a4ebb943 Chore: Refactor API send authentication logic 2026-03-09 11:08:19 +13:00
Ville Skyttä
ba00ea5a21 Chore: Switch to math/rand/v2
Insignificant as in tests only, but there's no particular reason not to.
2026-03-07 22:54:04 +13:00
Ville Skyttä
2afc52c6fe Chore: Refactor code with go fix
Done with `go fix ./...` using go 1.26.0.
2026-03-03 16:03:28 +13:00
dependabot[bot]
5e9c522402 Chore: Bump minimatch from 10.2.2 to 10.2.4
Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.2 to 10.2.4.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v10.2.2...v10.2.4)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 10.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 22:46:16 +13:00
Ralph Slooten
7bb330a07a Chore: Use last release + git hash in Docker edge versions 2026-03-02 22:38:38 +13:00
Ralph Slooten
ffb3067680 Merge tag 'v1.29.2' into develop
Release v1.29.2
2026-02-25 12:28:48 +13:00
48 changed files with 1139 additions and 837 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] = ""
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>

View File

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

View File

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

View File

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

View File

@@ -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",
);
}

View File

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

View File

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

View File

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