mirror of
https://github.com/axllent/mailpit.git
synced 2026-07-01 08:26:06 +00:00
Compare commits
29 Commits
v1.30.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eeff5e47c | ||
|
|
cd7661fd5b | ||
|
|
6acf5b8f94 | ||
|
|
1289635f71 | ||
|
|
bf4b6e6515 | ||
|
|
9d09cb1e28 | ||
|
|
acad7f4806 | ||
|
|
c57325e475 | ||
|
|
9dbb092447 | ||
|
|
7da82df24d | ||
|
|
c160224ad7 | ||
|
|
238251e19b | ||
|
|
0fb1c79f4b | ||
|
|
bf37405472 | ||
|
|
f1c325c5c3 | ||
|
|
66f066bd97 | ||
|
|
e6c92ff267 | ||
|
|
f2089b9366 | ||
|
|
ba27d695c2 | ||
|
|
a88dadbbe1 | ||
|
|
fc83f4881a | ||
|
|
2db18f671f | ||
|
|
8747cd81f9 | ||
|
|
ddfeab89d9 | ||
|
|
1e549eab06 | ||
|
|
deeab9b04c | ||
|
|
78fa3db33e | ||
|
|
a68499fa4e | ||
|
|
5c03d89109 |
2
.github/workflows/build-docker-edge.yml
vendored
2
.github/workflows/build-docker-edge.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/build-release.yml
vendored
2
.github/workflows/build-release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/tests-rqlite.yml
vendored
4
.github/workflows/tests-rqlite.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -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=.
|
||||
|
||||
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
34
go.mod
@@ -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
153
go.sum
@@ -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=
|
||||
|
||||
70
internal/healthcheck/healthcheck.go
Normal file
70
internal/healthcheck/healthcheck.go
Normal 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)
|
||||
}
|
||||
}
|
||||
88
internal/healthcheck/healthcheck_test.go
Normal file
88
internal/healthcheck/healthcheck_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
219
internal/linkcheck/throttle.go
Normal file
219
internal/linkcheck/throttle.go
Normal 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 ®istry{
|
||||
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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
1158
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user