Compare commits

...

29 Commits

Author SHA1 Message Date
dependabot[bot]
8eeff5e47c Build(deps): Bump github.com/klauspost/compress from 1.18.6 to 1.18.7
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.6 to 1.18.7.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.6...v1.18.7)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 07:22:49 +00:00
Ralph Slooten
cd7661fd5b Merge tag 'v1.30.3' into develop
Release v1.30.3
2026-06-27 21:28:14 +12:00
Ralph Slooten
6acf5b8f94 Merge branch 'release/v1.30.3' 2026-06-27 21:28:11 +12:00
Ralph Slooten
1289635f71 Release v1.30.3 2026-06-27 21:28:10 +12:00
Ralph Slooten
bf4b6e6515 Chore: Update node dependencies 2026-06-27 21:18:54 +12:00
Ralph Slooten
9d09cb1e28 Fix: Handle MaxBytesError in SendMessageHandler and return JSON error response 2026-06-27 21:16:30 +12:00
Ralph Slooten
acad7f4806 Chore: Update Go dependencies 2026-06-27 21:15:05 +12:00
Ralph Slooten
c57325e475 Feature: Add link check rate limiting and caching mechanism 2026-06-25 20:30:18 +12:00
Ralph Slooten
9dbb092447 Fix: Refactor Web UI configuration definitions in Swagger documentation 2026-06-19 21:51:55 +12:00
Ralph Slooten
7da82df24d Fix: Update Swagger response definitions for MessageHeadersResponse (#703) 2026-06-19 21:45:42 +12:00
Ralph Slooten
c160224ad7 Fix: Correctly parse after/before datetimes with timestamp in search query (#704) 2026-06-17 16:12:46 +12:00
Ralph Slooten
238251e19b Merge tag 'v1.30.2' into develop
Release v1.30.2
2026-06-17 15:36:50 +12:00
Ralph Slooten
0fb1c79f4b Merge branch 'release/v1.30.2' 2026-06-17 15:36:48 +12:00
Ralph Slooten
bf37405472 Release v1.30.2 2026-06-17 15:36:48 +12:00
Ralph Slooten
f1c325c5c3 Fix formatting 2026-06-17 15:28:20 +12:00
Ralph Slooten
66f066bd97 Chore: Update node dependencies 2026-06-17 15:25:20 +12:00
Ralph Slooten
e6c92ff267 Chore: Update Go dependencies 2026-06-17 15:24:25 +12:00
Ralph Slooten
f2089b9366 Merge branch 'bugfix/GHSA-w4mc-hhc6-xp28' into develop 2026-06-17 15:23:25 +12:00
Ralph Slooten
ba27d695c2 Chore: Update Github Actions dependencies 2026-06-16 21:33:45 +12:00
Ralph Slooten
a88dadbbe1 Security: Fix incomplete SSRF protection in IsInternalIP() detection for IPv6 transition mechanisms (GHSA-w4mc-hhc6-xp28) 2026-06-14 08:09:59 +12:00
Ralph Slooten
fc83f4881a Fix: Adjust header setting order in error response functions (#699) 2026-06-13 08:46:15 +12:00
Ralph Slooten
2db18f671f Chore: Toggle websocket compression using HTTP compression setting 2026-06-12 20:33:59 +12:00
Ralph Slooten
8747cd81f9 Chore: Compress websocket messages once per broadcast to improve performance (#695) 2026-06-11 20:09:42 +12:00
Ralph Slooten
ddfeab89d9 Merge branch 'feature/readyz' into develop 2026-06-11 16:35:35 +12:00
Ralph Slooten
1e549eab06 Test: Add readyz tests 2026-06-11 16:32:14 +12:00
Lyapunov Vadim
deeab9b04c Feature: Add wait support to readyz (#697) 2026-06-11 16:32:10 +12:00
Ralph Slooten
78fa3db33e Don't print allowed CORS origins when there are none (empty) 2026-06-11 16:22:24 +12:00
Ralph Slooten
a68499fa4e Don't print allowed CORS origins when there are none (empty) 2026-05-28 22:07:29 +12:00
Ralph Slooten
5c03d89109 Merge tag 'v1.30.1' into develop
Release v1.30.1
2026-05-28 22:00:36 +12:00
30 changed files with 1528 additions and 914 deletions

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0 # required for github-action-get-previous-tag
persist-credentials: false

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -21,7 +21,7 @@ jobs:
- goarch: arm
goos: windows
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -38,13 +38,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -71,4 +71,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -17,7 +17,7 @@ jobs:
# the HTTP address the rqlite node should advertise
HTTP_ADV_ADDR: "localhost:4001"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Go
@@ -25,7 +25,7 @@ jobs:
with:
go-version: 'stable'
cache-dependency-path: "**/*.sum"
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/healthcheck -v
env:
# set Mailpit to use the rqlite service container
MP_DATABASE: "http://localhost:4001"

View File

@@ -16,7 +16,7 @@ jobs:
with:
go-version: ${{ matrix.go-version }}
cache: false
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Go environment
@@ -33,7 +33,7 @@ jobs:
# https://olegk.dev/github-actions-and-go
run: gofmt -s -w . && git diff --exit-code
- name: Run Go tests
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid ./internal/healthcheck -v
- name: Run Go benchmarking
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.

View File

@@ -2,6 +2,44 @@
Notable changes to Mailpit will be documented in this file.
## [v1.30.3]
### Feature
- Add link check rate limiting and caching mechanism
### Chore
- Update Go dependencies
- Update node dependencies
### Fix
- Correctly parse after/before datetimes with timestamp in search query ([#704](https://github.com/axllent/mailpit/issues/704))
- Update Swagger response definitions for MessageHeadersResponse ([#703](https://github.com/axllent/mailpit/issues/703))
- Refactor Web UI configuration definitions in Swagger documentation
- Handle MaxBytesError in SendMessageHandler and return JSON error response
## [v1.30.2]
### Security
- Fix incomplete SSRF protection in IsInternalIP() detection for IPv6 transition mechanisms (GHSA-w4mc-hhc6-xp28)
### Feature
- Add wait support to readyz ([#697](https://github.com/axllent/mailpit/issues/697))
### Chore
- Compress websocket messages once per broadcast to improve performance ([#695](https://github.com/axllent/mailpit/issues/695))
- Toggle websocket compression using HTTP compression setting
- Update Github Actions dependencies
- Update Go dependencies
- Update node dependencies
### Fix
- Adjust header setting order in error response functions ([#699](https://github.com/axllent/mailpit/issues/699))
### Test
- Add readyz tests
## [v1.30.1]
### Security

View File

@@ -1,20 +1,19 @@
package cmd
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/healthcheck"
"github.com/spf13/cobra"
)
var (
useHTTPS bool
useHTTPS bool
readyzWait bool
readyzTimeout time.Duration
)
// readyzCmd represents the healthcheck command
@@ -22,33 +21,25 @@ var readyzCmd = &cobra.Command{
Use: "readyz",
Short: "Run a healthcheck to test if Mailpit is running",
Long: `This command connects to the /readyz endpoint of a running Mailpit server
and exits with a status of 0 if the connection is successful, else with a
and exits with a status of 0 if the connection is successful, else with a
status 1 if unhealthy.
If running within Docker, it should automatically detect environment
settings to determine the HTTP bind interface & port.
`,
Run: func(_ *cobra.Command, _ []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
proto = "https"
uri := healthcheck.URI(config.HTTPListen, config.Webroot, useHTTPS)
client := healthcheck.NewClient()
var err error
if readyzWait {
err = healthcheck.Wait(client, uri, readyzTimeout)
} else {
err = healthcheck.Check(client, uri)
}
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
conf := &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// 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}
res, err := client.Get(uri)
if err != nil || res.StatusCode != 200 {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
@@ -74,4 +65,6 @@ func init() {
readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port")
readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)")
readyzCmd.Flags().BoolVar(&readyzWait, "wait", readyzWait, "Wait until Mailpit is ready instead of checking once")
readyzCmd.Flags().DurationVar(&readyzTimeout, "timeout", 30*time.Second, "Maximum time to wait when --wait is set")
}

View File

@@ -108,6 +108,7 @@ func init() {
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)")
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
rootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, "allow-internal-http-requests", config.AllowInternalHTTPRequests, "Allow link checker, HTML checker & screenshots to access internal IP addresses")
rootCmd.Flags().BoolVar(&config.DisableLinkCheckRateLimit, "disable-link-check-rate-limit", config.DisableLinkCheckRateLimit, "Disable the per-domain rate limiter and result cache used by the link checker")
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
@@ -261,6 +262,9 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") {
config.AllowInternalHTTPRequests = true
}
if getEnabledFromEnv("MP_DISABLE_LINK_CHECK_RATE_LIMIT") {
config.DisableLinkCheckRateLimit = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}

View File

@@ -140,6 +140,11 @@ var (
// This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons.
AllowInternalHTTPRequests = false
// DisableLinkCheckRateLimit disables the per-domain rate limiter, concurrency
// cap, and result cache used by the link checker. Off by default; set when
// running in a trusted environment where the limiter's pacing is unwanted.
DisableLinkCheckRateLimit = false
// CLITagsArg is used to map the CLI args
CLITagsArg string

34
go.mod
View File

@@ -8,29 +8,29 @@ require (
github.com/axllent/ghru/v2 v2.2.3
github.com/axllent/semver v1.0.0
github.com/goccy/go-yaml v1.19.2
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime/v2 v2.4.0
github.com/klauspost/compress v1.18.6
github.com/kovidgoyal/imaging v1.8.21
github.com/jhillyerd/enmime/v2 v2.4.1
github.com/klauspost/compress v1.18.7
github.com/kovidgoyal/imaging v1.8.22
github.com/leporo/sqlf v1.4.0
github.com/pkg/errors v0.9.1
github.com/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9
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.33.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
github.com/tg123/go-htpasswd v1.2.5
github.com/vanng822/go-premailer v1.34.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.51.0
modernc.org/sqlite v1.53.0
)
require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
@@ -44,9 +44,9 @@ 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/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // 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.3.0 // indirect
@@ -57,10 +57,10 @@ require (
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
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/libc v1.72.5 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.73.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

153
go.sum
View File

@@ -2,8 +2,8 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcv
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
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/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
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.2.3 h1:nLzbq7jLiYQMxYPU4uBdgKL4jzAaMkBfAif3igpGaaE=
@@ -34,9 +34,8 @@ 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=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -53,25 +52,25 @@ github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jhillyerd/enmime/v2 v2.4.0 h1:6bPyyg2OPXEK1fKsLT89DntZf05LqaL2cIx+cvkEXTo=
github.com/jhillyerd/enmime/v2 v2.4.0/go.mod h1:TLpvqImPiumRecsJK5TYseRw2bPg3g0EtWc+SfU7cMs=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/jhillyerd/enmime/v2 v2.4.1 h1:VkBX8GJJ/wbQofWsKP3egRqgXcwmxlY94YUmXTj08kE=
github.com/jhillyerd/enmime/v2 v2.4.1/go.mod h1:TLpvqImPiumRecsJK5TYseRw2bPg3g0EtWc+SfU7cMs=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/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.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
github.com/kovidgoyal/imaging v1.8.22 h1:CtpoRXQpS79xxJsKu8+LUJJE/0i4FLquJZy0QH+QNlM=
github.com/kovidgoyal/imaging v1.8.22/go.mod h1:y8wo4JTv4D+skbtQf6fHg8nA1qtagvCcn8J2Nu5k2Jg=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -113,115 +112,51 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
github.com/tg123/go-htpasswd v1.2.5 h1:h+QdWCAp/FebK6fqjsqg9RGYcgEMcaiKNDV+Mg6uk3E=
github.com/tg123/go-htpasswd v1.2.5/go.mod h1:grOqB+sLpkA5ousKWPDRS2colmiBSGxlpuXrm8HxtXs=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
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.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=
github.com/vanng822/go-premailer v1.34.0 h1:CW7RUnjCfXrkuCbgC2wi/Cub7IwKslJWD/OkIBlcQUk=
github.com/vanng822/go-premailer v1.34.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
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=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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=
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.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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.2 h1:mxsy2FdrB6+qG3NfXefz1AmWv0ehOSDO4jxgxd7h9yo=
modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/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.72.5 h1:m2OGx9Ser1VvTS4Z9ZJlWs+CBMxutLaTiAWkNz+NB9U=
modernc.org/libc v1.72.5/go.mod h1:np0N7KDJ7eUtMZmOqVZNldrZyG+DHLl2B5pg8Hbar3U=
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
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=
@@ -230,8 +165,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
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

@@ -0,0 +1,70 @@
// Package healthcheck probes a running Mailpit instance's /readyz endpoint.
package healthcheck
import (
"crypto/tls"
"fmt"
"net/http"
"path"
"strings"
"time"
)
// PollInterval is the delay between polls in Wait. Exported as a variable so
// tests can shorten it.
var PollInterval = time.Second
// URI builds the readyz URL from a listen address, webroot, and TLS flag.
func URI(listen, webroot string, https bool) string {
proto := "http"
if https {
proto = "https"
}
root := strings.TrimRight(path.Join("/", webroot, "/"), "/") + "/"
return fmt.Sprintf("%s://%s%sreadyz", proto, listen, root)
}
// NewClient returns an HTTP client suitable for probing a Mailpit readyz
// endpoint. TLS verification is disabled because probes typically connect via
// IP, which won't match the server certificate.
func NewClient() *http.Client {
return &http.Client{Transport: &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}}
}
// Check makes a single readiness probe. Returns nil if the server responds
// with 200 OK.
func Check(client *http.Client, uri string) error {
res, err := client.Get(uri)
if err != nil {
return err
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", res.Status)
}
return nil
}
// Wait polls uri until Check succeeds or timeout elapses.
func Wait(client *http.Client, uri string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if err := Check(client, uri); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for Mailpit to become ready", timeout)
}
time.Sleep(PollInterval)
}
}

View File

@@ -0,0 +1,88 @@
package healthcheck
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCheck(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := Check(srv.Client(), srv.URL); err != nil {
t.Fatalf("Check() error = %v", err)
}
}
func TestWaitRetriesUntilSuccess(t *testing.T) {
oldPoll := PollInterval
PollInterval = time.Millisecond
t.Cleanup(func() { PollInterval = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := Wait(srv.Client(), srv.URL, 100*time.Millisecond); err != nil {
t.Fatalf("Wait() error = %v", err)
}
if calls < 2 {
t.Fatalf("Wait() calls = %d, want at least 2", calls)
}
}
func TestWaitTimesOut(t *testing.T) {
oldPoll := PollInterval
PollInterval = time.Millisecond
t.Cleanup(func() { PollInterval = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
if err := Wait(srv.Client(), srv.URL, 5*time.Millisecond); err == nil {
t.Fatal("Wait() error = nil, want timeout")
}
if calls == 0 {
t.Fatal("Wait() did not call the endpoint")
}
}
func TestURI(t *testing.T) {
tests := []struct {
name string
listen string
webroot string
https bool
want string
}{
{"plain", "127.0.0.1:8025", "", false, "http://127.0.0.1:8025/readyz"},
{"https", "127.0.0.1:8025", "", true, "https://127.0.0.1:8025/readyz"},
{"webroot", "127.0.0.1:8025", "/mailpit", false, "http://127.0.0.1:8025/mailpit/readyz"},
{"webroot trailing slash", "127.0.0.1:8025", "/mailpit/", false, "http://127.0.0.1:8025/mailpit/readyz"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := URI(tc.listen, tc.webroot, tc.https); got != tc.want {
t.Errorf("URI() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -1,7 +1,9 @@
package linkcheck
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/axllent/mailpit/internal/storage"
@@ -72,15 +74,47 @@ func TestLinkDetection(t *testing.T) {
m.Text = testTextLinks
m.HTML = testHTML
textLinks := extractTextLinks(&m)
textC := &linkCollector{seen: make(map[string]bool)}
extractTextLinks(&m, textC)
if !reflect.DeepEqual(textLinks, expectedTextLinks) {
if !reflect.DeepEqual(textC.links, expectedTextLinks) {
t.Fatalf("Failed to detect text links correctly")
}
htmlLinks := extractHTMLLinks(&m)
htmlC := &linkCollector{seen: make(map[string]bool)}
extractHTMLLinks(&m, htmlC)
if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {
if !reflect.DeepEqual(htmlC.links, expectedHTMLLinks) {
t.Fatalf("Failed to detect HTML links correctly")
}
}
func TestLinkLimit(t *testing.T) {
var html strings.Builder
html.WriteString("<html><body>")
for i := range maxUniqueLinks + 50 {
fmt.Fprintf(&html, `<a href="http://example.com/%d">link</a>`, i)
}
html.WriteString("</body></html>")
var text strings.Builder
for i := range 100 {
fmt.Fprintf(&text, " http://text-example.com/%d ", i)
}
m := storage.Message{HTML: html.String(), Text: text.String()}
c := &linkCollector{seen: make(map[string]bool)}
extractHTMLLinks(&m, c)
extractTextLinks(&m, c)
if len(c.links) != maxUniqueLinks {
t.Fatalf("expected %d links, got %d", maxUniqueLinks, len(c.links))
}
for _, l := range c.links {
if strings.HasPrefix(l, "http://text-example.com/") {
t.Fatalf("text extractor should not have run once HTML filled the collector, got %q", l)
}
}
}

View File

@@ -2,6 +2,7 @@
package linkcheck
import (
"context"
"regexp"
"strings"
@@ -12,13 +13,17 @@ import (
var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`)
// maxUniqueLinks caps how many unique links will be tested per message.
const maxUniqueLinks = 100
// RunTests will run all tests on an HTML string
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
func RunTests(ctx context.Context, msg *storage.Message, followRedirects bool) (Response, error) {
s := Response{}
allLinks := extractHTMLLinks(msg)
allLinks = strUnique(append(allLinks, extractTextLinks(msg)...))
s.Links = getHTTPStatuses(allLinks, followRedirects)
c := &linkCollector{seen: make(map[string]bool)}
extractHTMLLinks(msg, c)
extractTextLinks(msg, c)
s.Links = getHTTPStatuses(ctx, c.links, followRedirects)
for _, l := range s.Links {
if l.StatusCode >= 400 || l.StatusCode == 0 {
@@ -29,81 +34,91 @@ func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
return s, nil
}
func extractTextLinks(msg *storage.Message) []string {
// linkCollector accumulates unique links up to maxUniqueLinks.
type linkCollector struct {
seen map[string]bool
links []string
}
// full reports whether the collector has reached maxUniqueLinks.
func (c *linkCollector) full() bool {
return len(c.links) >= maxUniqueLinks
}
// add appends link if new and within capacity, returning false when the
// collector is full and the caller should stop producing more links.
func (c *linkCollector) add(link string) bool {
if c.full() {
return false
}
if !c.seen[link] {
c.seen[link] = true
c.links = append(c.links, link)
}
return !c.full()
}
func extractTextLinks(msg *storage.Message, c *linkCollector) {
if c.full() {
return
}
testLinkRe := regexp.MustCompile(`(?im)([^<]\b)((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+))`)
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
// recognize potential spaces in between the URL
// @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E
bracketLinkRe := regexp.MustCompile(`(?im)<((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;][^>]+))>`)
links := []string{}
// Cap the regex match count to bound work on very large bodies; the
// 3x multiplier leaves headroom for duplicates the collector will drop.
matchLimit := maxUniqueLinks * 3
matches := testLinkRe.FindAllStringSubmatch(msg.Text, -1)
matches := testLinkRe.FindAllStringSubmatch(msg.Text, matchLimit)
for _, match := range matches {
if len(match) > 0 {
links = append(links, match[2])
if !c.add(match[2]) {
return
}
}
}
angleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, -1)
angleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, matchLimit)
for _, match := range angleMatches {
if len(match) > 0 {
link := strings.ReplaceAll(match[1], "\n", "")
links = append(links, link)
if !c.add(link) {
return
}
}
}
return links
}
func extractHTMLLinks(msg *storage.Message) []string {
links := []string{}
func extractHTMLLinks(msg *storage.Message, c *linkCollector) {
if c.full() {
return
}
reader := strings.NewReader(msg.HTML)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return links
return
}
aLinks := doc.Find("a[href]").Nodes
for _, link := range aLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
for _, sel := range []struct{ selector, attr string }{
{"a[href]", "href"},
{`link[rel="stylesheet"]`, "href"},
{"img[src]", "src"},
} {
for _, node := range doc.Find(sel.selector).Nodes {
l, err := tools.GetHTMLAttributeVal(node, sel.attr)
if err != nil || !linkRe.MatchString(l) {
continue
}
if !c.add(l) {
return
}
}
}
cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range cssLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
imgLinks := doc.Find("img[src]").Nodes
for _, link := range imgLinks {
l, err := tools.GetHTMLAttributeVal(link, "src")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
return links
}
// strUnique return a slice of unique strings from a slice
func strUnique(strSlice []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range strSlice {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}

View File

@@ -17,26 +17,31 @@ import (
"github.com/axllent/mailpit/internal/tools"
)
func getHTTPStatuses(links []string, followRedirects bool) []Link {
// allow 5 threads
threads := make(chan int, 5)
results := make(map[string]Link, len(links))
resultsMutex := sync.RWMutex{}
output := []Link{}
func getHTTPStatuses(ctx context.Context, links []string, followRedirects bool) []Link {
results := make([]Link, len(links))
var wg sync.WaitGroup
var warnedDomains sync.Map
for i, l := range links {
if cached, ok := cachedLink(l); ok {
results[i] = cached
continue
}
for _, l := range links {
wg.Add(1)
go func(link string, w *sync.WaitGroup) {
threads <- 1 // will block if MAX threads
defer w.Done()
go func(idx int, link string) {
defer wg.Done()
code, err := doHead(link, followRedirects)
l := Link{}
l.URL = link
domain := registeredDomain(link)
release, err := acquireDomainSlot(ctx, domain, &warnedDomains)
if err != nil {
results[idx] = Link{URL: link, StatusCode: 0, Status: httpErrorSummary(err)}
return
}
defer release()
code, err := doHead(ctx, link, followRedirects)
l := Link{URL: link}
if err != nil {
l.StatusCode = 0
l.Status = httpErrorSummary(err)
@@ -48,25 +53,17 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
l.StatusCode = code
l.Status = http.StatusText(code)
}
resultsMutex.Lock()
results[link] = l
resultsMutex.Unlock()
<-threads // remove from threads
}(l, &wg)
results[idx] = l
storeLink(link, l)
}(i, l)
}
wg.Wait()
for _, l := range results {
output = append(output, l)
}
return output
return results
}
// Do a HEAD request to return HTTP status code
func doHead(link string, followRedirects bool) (int, error) {
func doHead(ctx context.Context, link string, followRedirects bool) (int, error) {
if !tools.IsValidLinkURL(link) {
return 0, fmt.Errorf("invalid URL: %s", link)
}
@@ -102,7 +99,7 @@ func doHead(link string, followRedirects bool) (int, error) {
},
}
req, err := http.NewRequest("HEAD", link, nil)
req, err := http.NewRequestWithContext(ctx, "HEAD", link, nil)
if err != nil {
logger.Log().Errorf("[link-check] %s", err.Error())
return 0, err

View File

@@ -0,0 +1,219 @@
package linkcheck
import (
"container/list"
"context"
"net/url"
"strings"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"golang.org/x/net/publicsuffix"
"golang.org/x/time/rate"
)
// Per-domain rate-limiter parameters. The bucket starts full so a single
// fresh check of a 100-link newsletter completes without waiting; refill
// caps sustained traffic to any one registered domain at 1 req/s across
// all concurrent API calls.
const (
perDomainBurst = 100
perDomainRefill = rate.Limit(1)
perDomainConcurrency = 2
// limiterRegistryCap bounds memory regardless of attacker effort.
// Eviction prefers buckets at full capacity (safe to drop).
limiterRegistryCap = 10000
// resultCacheTTL deduplicates repeated checks of the same URL so a
// user retesting the same email doesn't drain the rate limiter twice
// and an attacker can't multiply outbound load by looping the API.
resultCacheTTL = 60 * time.Second
)
type domainState struct {
limiter *rate.Limiter
sem chan struct{}
lruElem *list.Element
}
type registry struct {
mu sync.Mutex
entries map[string]*domainState
lru *list.List // front = most recently used
}
func newRegistry() *registry {
return &registry{
entries: make(map[string]*domainState),
lru: list.New(),
}
}
// get returns the state for a registered domain, creating it on demand.
// When the registry is at capacity, prefers to evict entries whose bucket
// is at full capacity (no security cost — recreating yields identical state).
func (r *registry) get(domain string) *domainState {
r.mu.Lock()
defer r.mu.Unlock()
if st, ok := r.entries[domain]; ok {
r.lru.MoveToFront(st.lruElem)
return st
}
if len(r.entries) >= limiterRegistryCap {
r.evictLocked()
}
st := &domainState{
limiter: rate.NewLimiter(perDomainRefill, perDomainBurst),
sem: make(chan struct{}, perDomainConcurrency),
}
st.lruElem = r.lru.PushFront(domainKey{domain: domain, state: st})
r.entries[domain] = st
return st
}
type domainKey struct {
domain string
state *domainState
}
// evictLocked drops one entry. Caller must hold r.mu.
// Walks the LRU from the back looking for a full bucket; if none, drops the LRU.
func (r *registry) evictLocked() {
for e := r.lru.Back(); e != nil; e = e.Prev() {
k := e.Value.(domainKey)
if k.state.limiter.Tokens() >= float64(perDomainBurst) {
r.lru.Remove(e)
delete(r.entries, k.domain)
return
}
}
e := r.lru.Back()
if e == nil {
return
}
k := e.Value.(domainKey)
r.lru.Remove(e)
delete(r.entries, k.domain)
}
type cachedResult struct {
link Link
expires time.Time
}
type resultCache struct {
mu sync.Mutex
entries map[string]cachedResult
}
func newResultCache() *resultCache {
return &resultCache{entries: make(map[string]cachedResult)}
}
func (c *resultCache) get(u string) (Link, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[u]
if !ok {
return Link{}, false
}
if time.Now().After(e.expires) {
delete(c.entries, u)
return Link{}, false
}
return e.link, true
}
func (c *resultCache) put(u string, l Link) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[u] = cachedResult{link: l, expires: time.Now().Add(resultCacheTTL)}
// Opportunistic sweep: when the cache grows past a threshold,
// drop expired entries. Avoids unbounded growth without a goroutine.
if len(c.entries) > 2*limiterRegistryCap {
now := time.Now()
for k, v := range c.entries {
if now.After(v.expires) {
delete(c.entries, k)
}
}
}
}
var (
domainRegistry = newRegistry()
linkCache = newResultCache()
)
// registeredDomain returns the eTLD+1 for a URL's host, or the lowercased
// host if no registered domain can be determined (e.g. IP literals).
// Subdomains share the same key so wildcard-DNS bypass is closed.
func registeredDomain(rawurl string) string {
u, err := url.Parse(rawurl)
if err != nil {
return ""
}
host := strings.ToLower(u.Hostname())
if host == "" {
return ""
}
d, err := publicsuffix.EffectiveTLDPlusOne(host)
if err != nil {
return host
}
return d
}
// acquireDomainSlot blocks until both a rate-limit token and a per-domain
// concurrency slot are available, or ctx is cancelled. Returns a release
// function that must be called when the request completes.
func acquireDomainSlot(ctx context.Context, domain string, warned *sync.Map) (release func(), err error) {
if config.DisableLinkCheckRateLimit {
return func() {}, nil
}
st := domainRegistry.get(domain)
if st.limiter.Tokens() < 1 {
if _, alreadyWarned := warned.LoadOrStore(domain, struct{}{}); !alreadyWarned {
logger.Log().Warnf("[link-check] rate limiting active for %s - use --disable-link-check-rate-limit to disable", domain)
}
}
if err := st.limiter.Wait(ctx); err != nil {
return nil, err
}
select {
case st.sem <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
return func() { <-st.sem }, nil
}
// cachedLink returns a previously-checked result if still fresh.
func cachedLink(u string) (Link, bool) {
if config.DisableLinkCheckRateLimit {
return Link{}, false
}
return linkCache.get(u)
}
// storeLink caches a result so repeat checks of the same URL skip the
// rate limiter and the outbound HEAD.
func storeLink(u string, l Link) {
if config.DisableLinkCheckRateLimit {
return
}
linkCache.put(u, l)
}

View File

@@ -465,7 +465,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
q.Where("Attachments > 0")
}
} else if strings.HasPrefix(lw, "after:") {
w = cleanString(w[6:])
w = strings.ToUpper(cleanString(w[6:]))
if w != "" {
t, err := dateparse.ParseIn(w, loc)
if err != nil {
@@ -480,7 +480,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
}
}
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
w = strings.ToUpper(cleanString(w[7:]))
if w != "" {
t, err := dateparse.ParseIn(w, loc)
if err != nil {

View File

@@ -1,35 +1,111 @@
package tools
import (
"encoding/binary"
"net"
"net/url"
)
// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate().
// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique
// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses
// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network.
var cgnatRange = func() *net.IPNet {
_, cidr, _ := net.ParseCIDR("100.64.0.0/10")
var (
// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate().
// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique
// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses
// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network.
cgnatRange = mustCIDR("100.64.0.0/10")
// IPv6 transition prefixes that embed an IPv4 destination. Go's net.IP.Is* family
// does not decode these, so an IPv6 literal of one of these forms can carry a
// private/link-local IPv4 destination past the stdlib checks. See golang/go#79925.
nat64WellKnown = mustCIDR("64:ff9b::/96") // RFC 6052
nat64LocalUse = mustCIDR("64:ff9b:1::/48") // RFC 8215
sixToFour = mustCIDR("2002::/16") // RFC 3056
teredo = mustCIDR("2001::/32") // RFC 4380
ipv4Compatible = mustCIDR("::/96") // RFC 4291 §2.5.5.1
// IPv4-mapped IPv6 (::ffff:0:0/96, RFC 4291 §2.5.5.2) is normalised by net.IP.To4,
// so the stdlib Is* checks above already see the embedded IPv4 - no decode needed.
// Direct IPv6 prefixes outside the scope of Go's stdlib Is* family.
deprecatedSiteLocal = mustCIDR("fec0::/10") // RFC 3879 / RFC 4291 §2.5.7 — deprecated, still routable on dual-stack hosts
documentationPrefix = mustCIDR("2001:db8::/32") // RFC 3849 — documentation only, must not appear in real traffic
)
// MustCIDR is a helper for use in global var initialisation.
func mustCIDR(s string) *net.IPNet {
_, cidr, _ := net.ParseCIDR(s)
return cidr
}()
}
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
// IsLoopback 127.0.0.0/8, ::1
// IsPrivate 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
// IsLinkLocalUnicast 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
// IsLinkLocalMulticast 224.0.0.0/24, ff02::/16
// IsUnspecified 0.0.0.0, ::
// IsMulticast 224.0.0.0/4, ff00::/8
// CGNAT 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT)
// IsLoopback - 127.0.0.0/8, ::1
// IsPrivate - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
// IsLinkLocalUnicast - 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
// IsLinkLocalMulticast - 224.0.0.0/24, ff02::/16
// IsUnspecified - 0.0.0.0, ::
// IsMulticast - 224.0.0.0/4, ff00::/8
// CGNAT - 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT)
// IPv6 transition forms - NAT64 (RFC 6052/8215), 6to4 (RFC 3056), Teredo (RFC 4380),
// IPv4-compatible (RFC 4291) - re-checked against their embedded IPv4.
func IsInternalIP(ip net.IP) bool {
return ip.IsLoopback() ||
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
cgnatRange.Contains(ip)
cgnatRange.Contains(ip) ||
deprecatedSiteLocal.Contains(ip) ||
documentationPrefix.Contains(ip) {
return true
}
if embeddedV4, ok := embeddedIPv4(ip); ok {
return IsInternalIP(embeddedV4)
}
return false
}
// embeddedIPv4 returns the IPv4 destination encoded in ip, if ip is an IPv6 form
// documented to carry one. Without this, an IPv6 literal like 64:ff9b::a9fe:a9fe
// (NAT64 wrapping 169.254.169.254) bypasses the stdlib Is* checks above.
func embeddedIPv4(ip net.IP) (net.IP, bool) {
// Skip addresses that are already IPv4 (4-byte or IPv4-mapped IPv6) - those are
// covered by the stdlib Is* checks via To4 normalisation. Re-entering here would
// recurse infinitely, because To16 turns an IPv4 back into ::ffff:<ipv4>.
if ip.To4() != nil {
return nil, false
}
ip16 := ip.To16()
if ip16 == nil || len(ip16) != net.IPv6len {
return nil, false
}
switch {
case nat64WellKnown.Contains(ip16), nat64LocalUse.Contains(ip16),
ipv4Compatible.Contains(ip16):
// Last 32 bits are the embedded IPv4.
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
case sixToFour.Contains(ip16):
// Bits 16..47 are the embedded IPv4.
return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5]).To4(), true
case teredo.Contains(ip16):
// Bits 96..127 are the embedded IPv4 XOR'd with 0xFFFFFFFF.
x := binary.BigEndian.Uint32(ip16[12:16]) ^ 0xFFFFFFFF
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, x)
return net.IPv4(b[0], b[1], b[2], b[3]).To4(), true
case ip16[10] == 0x5e && ip16[11] == 0xfe:
// ISATAP (RFC 5214) - interface identifier ends with :5efe:<ipv4>. The /64
// prefix is not fixed (any subnet can carry ISATAP), so match structurally
// on bytes 10-11 and treat bytes 12-15 as the embedded IPv4. Must run after
// the fixed-prefix cases above (Teredo can legitimately have 5efe in bytes
// 10-11; its embedding takes precedence).
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
}
return nil, false
}
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.

View File

@@ -19,11 +19,27 @@ func TestIsInternalIP(t *testing.T) {
"224.0.0.1", // multicast
"100.64.0.1", // CGNAT start
"100.127.255.255", // CGNAT end
// IPv6 transition forms embedding an internal IPv4 destination — golang/go#79925.
"64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) wrapping 169.254.169.254
"64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215) wrapping 169.254.169.254
"2002:a9fe:a9fe::", // 6to4 (RFC 3056) wrapping 169.254.169.254
"::a9fe:a9fe", // IPv4-compatible IPv6 (RFC 4291) wrapping 169.254.169.254
"64:ff9b::7f00:1", // NAT64 wrapping 127.0.0.1
"2002:0a00:0001::", // 6to4 wrapping 10.0.0.1
"::ffff:169.254.169.254", // IPv4-mapped (also caught by stdlib via To4)
"::5efe:a9fe:a9fe", // ISATAP (RFC 5214) wrapping 169.254.169.254
"2001:db8::5efe:7f00:1", // ISATAP under a documentation prefix wrapping 127.0.0.1
"fec0::1", // deprecated site-local (RFC 3879 / RFC 4291 §2.5.7)
"2001:db8::1", // documentation prefix (RFC 3849)
"2001:db8::5efe:0808:0808", // documentation prefix (blocked regardless of embedded IPv4)
}
external := []string{
"8.8.8.8",
"1.1.1.1",
"100.128.0.1", // just outside CGNAT range
"100.128.0.1", // just outside CGNAT range
"2001:4860:4860::8888", // Google public DNS over IPv6
"2002:0808:0808::", // 6to4 wrapping 8.8.8.8 (public IPv4)
"64:ff9b::0808:0808", // NAT64 wrapping 8.8.8.8 (public IPv4)
}
for _, s := range internal {

1158
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,8 @@ import (
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, "404 page not found")
}
@@ -25,8 +25,8 @@ func fourOFour(w http.ResponseWriter) {
func httpError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprint(w, msg)
}
@@ -34,10 +34,10 @@ func httpError(w http.ResponseWriter, msg string) {
func httpJSONError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
e := struct{ Error string }{Error: msg}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(e); err != nil {
httpError(w, err.Error())
}

View File

@@ -123,7 +123,7 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
f := r.URL.Query().Get("follow")
followRedirects := f == "true" || f == "1"
summary, err := linkcheck.RunTests(msg, followRedirects)
summary, err := linkcheck.RunTests(r.Context(), msg, followRedirects)
if err != nil {
httpError(w, err.Error())
return

View File

@@ -54,7 +54,10 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(struct{ Error string }{Error: err.Error()})
return
}
httpJSONError(w, err.Error())
return

View File

@@ -53,49 +53,53 @@ type jsonErrorResponse struct {
}
}
// Web UI configuration settings
// swagger:model WebUIConfiguration
type WebUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
Enabled bool
// The configured SMTP server address
SMTPServer string
// Enforced Return-Path (if set) for relay bounces
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// Overrides the "From" address for all relayed messages
OverrideFrom string
// Preserve the original Message-IDs when relaying messages
PreserveMessageIDs bool
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
}
// Whether SpamAssassin is enabled
SpamAssassin bool
// Whether Chaos support is enabled at runtime
ChaosEnabled bool
// Whether messages with duplicate IDs are ignored
DuplicatesIgnored bool
// Whether the delete button should be hidden
HideDeleteAllButton bool
}
// Web UI configuration response
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
//
// in: body
Body struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
Enabled bool
// The configured SMTP server address
SMTPServer string
// Enforced Return-Path (if set) for relay bounces
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// Overrides the "From" address for all relayed messages
OverrideFrom string
// Preserve the original Message-IDs when relaying messages
PreserveMessageIDs bool
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
}
// Whether SpamAssassin is enabled
SpamAssassin bool
// Whether Chaos support is enabled at runtime
ChaosEnabled bool
// Whether messages with duplicate IDs are ignored
DuplicatesIgnored bool
// Whether the delete button should be hidden
HideDeleteAllButton bool
}
Body WebUIConfiguration
}
// Application information
@@ -117,7 +121,7 @@ type chaosResponse struct {
}
// Message headers
// swagger:model MessageHeadersResponse
// swagger:response MessageHeadersResponse
type messageHeadersResponse map[string][]string
// Summary of messages

View File

@@ -79,6 +79,10 @@ func setCORSOrigins() {
corsAllowOrigins[asciiFoldString(host)] = true
}
if len(corsAllowOrigins) == 0 {
return
}
if _, wildCard := corsAllowOrigins["*"]; wildCard {
// reset to just wildcard
corsAllowOrigins = make(map[string]bool)

View File

@@ -174,10 +174,7 @@
],
"responses": {
"200": {
"description": "MessageHeadersResponse",
"schema": {
"$ref": "#/definitions/MessageHeadersResponse"
}
"$ref": "#/responses/MessageHeadersResponse"
},
"400": {
"$ref": "#/responses/ErrorResponse"
@@ -1774,18 +1771,6 @@
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
},
"MessageHeadersResponse": {
"description": "Message headers",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-go-name": "messageHeadersResponse",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"MessageSummary": {
"description": "MessageSummary struct for frontend messages",
"type": "object",
@@ -1875,7 +1860,7 @@
"type": "object",
"properties": {
"messages": {
"description": "Messages summary\nin: body",
"description": "Messages summary",
"type": "array",
"items": {
"$ref": "#/definitions/MessageSummary"
@@ -1970,6 +1955,67 @@
},
"x-go-name": "Result",
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
},
"WebUIConfiguration": {
"description": "Web UI configuration settings",
"type": "object",
"properties": {
"ChaosEnabled": {
"description": "Whether Chaos support is enabled at runtime",
"type": "boolean"
},
"DuplicatesIgnored": {
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"HideDeleteAllButton": {
"description": "Whether the delete button should be hidden",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"
},
"MessageRelay": {
"description": "Message Relay information",
"type": "object",
"properties": {
"AllowedRecipients": {
"description": "Only allow relaying to these recipients (regex)",
"type": "string"
},
"BlockedRecipients": {
"description": "Block relaying to these recipients (regex)",
"type": "string"
},
"Enabled": {
"description": "Whether message relaying (release) is enabled",
"type": "boolean"
},
"OverrideFrom": {
"description": "Overrides the \"From\" address for all relayed messages",
"type": "string"
},
"PreserveMessageIDs": {
"description": "Preserve the original Message-IDs when relaying messages",
"type": "boolean"
},
"ReturnPath": {
"description": "Enforced Return-Path (if set) for relay bounces",
"type": "string"
},
"SMTPServer": {
"description": "The configured SMTP server address",
"type": "string"
}
}
},
"SpamAssassin": {
"description": "Whether SpamAssassin is enabled",
"type": "boolean"
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
}
},
"responses": {
@@ -2025,6 +2071,18 @@
}
}
},
"MessageHeadersResponse": {
"description": "Message headers",
"schema": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"MessagesSummaryResponse": {
"description": "Summary of messages",
"schema": {
@@ -2065,63 +2123,7 @@
"WebUIConfigurationResponse": {
"description": "Web UI configuration response",
"schema": {
"type": "object",
"properties": {
"ChaosEnabled": {
"description": "Whether Chaos support is enabled at runtime",
"type": "boolean"
},
"DuplicatesIgnored": {
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"HideDeleteAllButton": {
"description": "Whether the delete button should be hidden",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"
},
"MessageRelay": {
"description": "Message Relay information",
"type": "object",
"properties": {
"AllowedRecipients": {
"description": "Only allow relaying to these recipients (regex)",
"type": "string"
},
"BlockedRecipients": {
"description": "Block relaying to these recipients (regex)",
"type": "string"
},
"Enabled": {
"description": "Whether message relaying (release) is enabled",
"type": "boolean"
},
"OverrideFrom": {
"description": "Overrides the \"From\" address for all relayed messages",
"type": "string"
},
"PreserveMessageIDs": {
"description": "Preserve the original Message-IDs when relaying messages",
"type": "boolean"
},
"ReturnPath": {
"description": "Enforced Return-Path (if set) for relay bounces",
"type": "string"
},
"SMTPServer": {
"description": "The configured SMTP server address",
"type": "string"
}
}
},
"SpamAssassin": {
"description": "Whether SpamAssassin is enabled",
"type": "boolean"
}
}
"$ref": "#/definitions/WebUIConfiguration"
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/gorilla/websocket"
@@ -32,7 +33,7 @@ var (
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
EnableCompression: true,
EnableCompression: !config.DisableHTTPCompression,
CheckOrigin: func(_ *http.Request) bool {
// origin is checked via server.go's CORS settings
return true
@@ -47,7 +48,7 @@ type Client struct {
conn *websocket.Conn
// Buffered channel of outbound messages.
send chan []byte
send chan *websocket.PreparedMessage
}
// ReadPump is used here solely to monitor the connection, not to actually receive messages.
@@ -90,7 +91,7 @@ func (c *Client) writePump() {
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
if err := c.conn.WritePreparedMessage(message); err != nil {
return
}
case <-ticker.C:
@@ -124,7 +125,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client := &Client{hub: hub, conn: conn, send: make(chan *websocket.PreparedMessage, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in new goroutines.

View File

@@ -6,6 +6,7 @@ import (
"sync/atomic"
"github.com/axllent/mailpit/internal/logger"
"github.com/gorilla/websocket"
)
// Hub maintains the set of active clients and broadcasts messages to the
@@ -61,9 +62,14 @@ func (h *Hub) Run() {
h.clientCount.Add(-1)
}
case message := <-h.Broadcast:
prepared, err := websocket.NewPreparedMessage(websocket.TextMessage, message)
if err != nil {
logger.Log().Errorf("[websocket] error preparing message: %s", err.Error())
continue
}
for client := range h.Clients {
select {
case client.send <- message:
case client.send <- prepared:
default:
close(client.send)
delete(h.Clients, client)