mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-28 06:56:06 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8756a32c | ||
|
|
a9058f40db | ||
|
|
3b65ee936a | ||
|
|
bb81b62357 | ||
|
|
e27d30bda7 | ||
|
|
cae0f638af | ||
|
|
786f263d32 | ||
|
|
8041eac509 | ||
|
|
b7e4146dbf | ||
|
|
5ec074208c | ||
|
|
b82960928a | ||
|
|
4ab532b9aa | ||
|
|
35079d182c | ||
|
|
04c779994b | ||
|
|
bcd1bc71ee | ||
|
|
136bdde953 | ||
|
|
499a543963 | ||
|
|
8b4c9d1267 | ||
|
|
1cabac31ad | ||
|
|
da7b82378c | ||
|
|
0702241fa5 | ||
|
|
8d72191704 | ||
|
|
052afdf929 | ||
|
|
c1fbbffded | ||
|
|
6e2c42d2bc | ||
|
|
da8eb3ece8 | ||
|
|
4502cdc358 | ||
|
|
fbb63c89dd | ||
|
|
71bd44bbb5 | ||
|
|
b997fff7eb | ||
|
|
034a480a39 | ||
|
|
f575b53854 | ||
|
|
d469aac87c | ||
|
|
e4c3442e39 | ||
|
|
f11fc1ffe0 | ||
|
|
40c5936f79 | ||
|
|
8bc966e618 | ||
|
|
ec2a0851ab | ||
|
|
4bdbeebcc0 | ||
|
|
10430f7dce | ||
|
|
878c68bb49 | ||
|
|
86b0cf8557 | ||
|
|
123ec9a354 | ||
|
|
3b2423bdf1 |
11
.github/workflows/build-docker-edge.yml
vendored
11
.github/workflows/build-docker-edge.yml
vendored
@@ -39,13 +39,22 @@ jobs:
|
||||
uses: benjlevesque/short-sha@v3.0
|
||||
id: short-sha
|
||||
|
||||
- name: Calculate next patch version
|
||||
id: next-version
|
||||
run: |
|
||||
TAG="${{ steps.previous-tag.outputs.tag }}"
|
||||
VERSION="${TAG#v}"
|
||||
BASE="${VERSION%.*}"
|
||||
PATCH="${VERSION##*.}"
|
||||
echo "version=v${BASE}.$((PATCH + 1))-${{ steps.short-sha.outputs.sha }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}"
|
||||
"VERSION=${{ steps.next-version.outputs.version }}"
|
||||
push: true
|
||||
tags: |
|
||||
axllent/mailpit:edge
|
||||
|
||||
2
.github/workflows/build-release.yml
vendored
2
.github/workflows/build-release.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
- run: echo "Building assets for ${{ github.ref_name }}"
|
||||
- run: npm install
|
||||
- run: npm ci
|
||||
- run: npm run package
|
||||
|
||||
# build the binaries
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -31,7 +31,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 -v
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v
|
||||
- name: Run Go benchmarking
|
||||
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
cache: 'npm'
|
||||
- name: Install JavaScript dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm install
|
||||
run: npm ci
|
||||
- name: Run JavaScript linting
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run lint
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,13 +2,51 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.29.7]
|
||||
## [v1.30.0]
|
||||
|
||||
### Security
|
||||
- Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes (GHSA-fpxj-m5q8-fphw)
|
||||
- Include CGNAT (Carrier-Grade NAT) in internal IP checks (GHSA-j3fj-qppj-fmmc)
|
||||
- Block internal IP access by default in HTML check (GHSA-j3fj-qppj-fmmc)
|
||||
- Fix for path traversal & arbitrary file write in mailpit dump --http via attacker-controlled message IDs (GHSA-qx5x-85p8-vg4j)
|
||||
- Fix concurrent map read & write in proxy CSS rewriter (GHSA-w4vj-r5pg-3722)
|
||||
|
||||
### Feature
|
||||
- New loading indicator, reduce flash during message transitions ([#682](https://github.com/axllent/mailpit/issues/682))
|
||||
|
||||
### Chore
|
||||
- Bump vue-router from 4.6.4 to 5.0.4
|
||||
- Bump axios version to 1.15.0
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
- Remove gorilla/mux dependency and replace with stdlib routing
|
||||
- Remove logrus dependency and implement slog-based logging
|
||||
- Remove go-telnet dependency and implement TCP/Unix socket handling for SMTP
|
||||
- Replace lithammer/shortuuid with custom shortuuid implementation and update tests
|
||||
- Improve iframe height adjustment with optional chaining
|
||||
- Bump axios version to v1.16.0
|
||||
- Refactor Prometheus metrics implementation and remove unused dependencies
|
||||
- Refactor MarkRead and MarkUnread functions to only broadcast changes of modified messages
|
||||
- Optimize tag retrieval by batching message IDs in List and Search functions
|
||||
- Enhance SetMessageTags function to improve tag handling and batch deletions
|
||||
- Optimize MarkRead and MarkUnread functions to reduce database calls and improve performance
|
||||
- Refactor pruneMessages function to eliminate duplicate ID checks using a map
|
||||
- Refactor addMessageTag function to remove mutex and ensure safe concurrent inserts
|
||||
- Refactor Hub to use atomic clientCount for safe concurrent client tracking
|
||||
- Ensure websocket connection is closed on client unregistration
|
||||
- Simplify writePump by using WriteMessage and remove unnecessary newline handling
|
||||
- Add message dump max-message-size flag and refactor message handling
|
||||
- Add message ingest max-message-size flag and refactor message handling
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
- Update caniemail test database
|
||||
|
||||
### Fix
|
||||
- Validate SMTP XCLIENT args before processing
|
||||
|
||||
### Build
|
||||
- Update CI actions to use `npm ci`
|
||||
- Tag Docker edge build with next patch versions
|
||||
|
||||
|
||||
## [v1.29.6]
|
||||
|
||||
@@ -7,7 +7,7 @@ COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk upgrade && apk add git npm && \
|
||||
npm install && npm run package && \
|
||||
npm ci && npm run package && \
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -47,8 +47,8 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
|
||||
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
|
||||
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
|
||||
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
|
||||
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
|
||||
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 200-300 emails per second over SMTP depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails, with automatic email pruning by volume or message age (by default keeping the most recent 500 emails)
|
||||
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// dumpCmd represents the dump command
|
||||
var dumpCmd = &cobra.Command{
|
||||
Use: "dump <database> <output-dir>",
|
||||
Use: "dump <output-dir>",
|
||||
Short: "Dump all messages from a database to a directory",
|
||||
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
|
||||
|
||||
@@ -30,7 +30,8 @@ func init() {
|
||||
dumpCmd.Flags().SortFlags = false
|
||||
|
||||
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
|
||||
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
|
||||
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
|
||||
dumpCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
|
||||
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
|
||||
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -34,6 +34,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
limit := int64(config.MaxMessageSize) * 1024 * 1024
|
||||
|
||||
for _, a := range args {
|
||||
err := filepath.Walk(a,
|
||||
@@ -42,7 +43,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
logger.Log().Error(err)
|
||||
return nil
|
||||
}
|
||||
if !isFile(path) {
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,6 +51,11 @@ The --recent flag will only consider files with a modification date within the l
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 && info.Size() > limit {
|
||||
logger.Log().Warnf("%s exceeds %d MiB size cap, skipping", path, config.MaxMessageSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
@@ -57,11 +63,19 @@ The --recent flag will only consider files with a modification date within the l
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
var reader io.Reader = f
|
||||
if config.MaxMessageSize > 0 {
|
||||
reader = io.LimitReader(f, limit+1)
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
return nil
|
||||
}
|
||||
if config.MaxMessageSize > 0 && int64(len(body)) > limit {
|
||||
logger.Log().Warnf("%s exceeds %d MiB size cap, skipping", path, config.MaxMessageSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -87,22 +101,33 @@ The --recent flag will only consider files with a modification date within the l
|
||||
}
|
||||
}
|
||||
|
||||
if sendmail.FromAddr == "" {
|
||||
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
|
||||
sendmail.FromAddr = fromAddresses[0].Address
|
||||
}
|
||||
// Parse the message's From: header once for this iteration.
|
||||
// Do NOT mutate the package-level sendmail.FromAddr — that
|
||||
// is the CLI default and would leak across messages.
|
||||
var msgFrom string
|
||||
if fromAddresses, err := msg.Header.AddressList("From"); err == nil && len(fromAddresses) > 0 {
|
||||
msgFrom = fromAddresses[0].Address
|
||||
}
|
||||
|
||||
if len(recipients) == 0 {
|
||||
// Bcc
|
||||
recipients = []string{sendmail.FromAddr}
|
||||
// Bcc — fall back to the message's own From, or the
|
||||
// CLI-configured default if the message has none.
|
||||
fallback := msgFrom
|
||||
if fallback == "" {
|
||||
fallback = sendmail.FromAddr
|
||||
}
|
||||
recipients = []string{fallback}
|
||||
}
|
||||
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
// Return-Path per RFC 5321 is "<addr>" (or "<>" for null).
|
||||
// Use mail.ParseAddress so we only strip the wrapping
|
||||
// angle brackets, not stray "<"/">" inside the value.
|
||||
var returnPath string
|
||||
if rp, err := mail.ParseAddress(msg.Header.Get("Return-Path")); err == nil {
|
||||
returnPath = rp.Address
|
||||
}
|
||||
if returnPath == "" {
|
||||
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
|
||||
returnPath = fromAddresses[0].Address
|
||||
}
|
||||
returnPath = msgFrom
|
||||
}
|
||||
|
||||
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
|
||||
@@ -134,16 +159,7 @@ func init() {
|
||||
|
||||
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
|
||||
}
|
||||
|
||||
// IsFile returns if a path is a file
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
ingestCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
|
||||
}
|
||||
|
||||
// Format a an integer 10000 => 10,000
|
||||
|
||||
@@ -92,6 +92,7 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
|
||||
rootCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
@@ -106,7 +107,7 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
|
||||
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 & screenshots to access internal IP addresses")
|
||||
rootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, "allow-internal-http-requests", config.AllowInternalHTTPRequests, "Allow link checker, HTML checker & screenshots to access internal IP addresses")
|
||||
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)")
|
||||
@@ -219,6 +220,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_MAX_AGE")) > 0 {
|
||||
config.MaxAge = os.Getenv("MP_MAX_AGE")
|
||||
}
|
||||
if len(os.Getenv("MP_MAX_MESSAGE_SIZE")) > 0 {
|
||||
config.MaxMessageSize, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGE_SIZE"))
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ var (
|
||||
// however some servers accept more.
|
||||
SMTPMaxRecipients = 100
|
||||
|
||||
// MaxMessageSize is the maximum size of an inbound message, in megabytes (MiB).
|
||||
// Applies to both SMTP DATA payloads and the HTTP /api/v1/send body.
|
||||
// 0 disables the limit (not recommended on network-reachable listeners).
|
||||
MaxMessageSize = 50
|
||||
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
@@ -325,6 +330,10 @@ func VerifyConfig() error {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
if MaxMessageSize == 0 {
|
||||
logger.Log().Warnf("[smtpd] no message limit set, this is not recommended for network-reachable listeners")
|
||||
}
|
||||
|
||||
// Web UI & API
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
41
go.mod
41
go.mod
@@ -8,34 +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-20260412113850-134a5b2cce7f
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
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.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // 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,36 +39,28 @@ require (
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inbucket/html2text v1.0.0 // indirect
|
||||
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-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/errors v1.3.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.4 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
98
go.sum
98
go.sum
@@ -10,8 +10,6 @@ github.com/axllent/ghru/v2 v2.2.3 h1:nLzbq7jLiYQMxYPU4uBdgKL4jzAaMkBfAif3igpGaaE
|
||||
github.com/axllent/ghru/v2 v2.2.3/go.mod h1:tyH60pqmLCDHd3UMOZyiedrYMFVLwBQqPQ5y8WLvDzA=
|
||||
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
|
||||
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -36,8 +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-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -47,8 +45,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -59,39 +55,31 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
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-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
|
||||
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
@@ -100,27 +88,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9 h1:TS0KUGThBdgr2QURBtaUdNdcRJuwZ1O7/FnhrTDRp0c=
|
||||
github.com/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -146,10 +122,6 @@ github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1r
|
||||
github.com/vanng822/go-premailer v1.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic=
|
||||
github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -157,17 +129,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
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=
|
||||
@@ -177,8 +149,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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=
|
||||
@@ -199,8 +171,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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=
|
||||
@@ -219,8 +191,8 @@ 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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
@@ -229,19 +201,17 @@ 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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
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=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
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.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
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=
|
||||
@@ -250,18 +220,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
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=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
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=
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -18,9 +20,24 @@ import (
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
)
|
||||
|
||||
// httpClient bounds each remote request so a slow or hostile --http endpoint
|
||||
// cannot hang the dump indefinitely.
|
||||
var httpClient = &http.Client{Timeout: time.Minute}
|
||||
|
||||
// maxSummarySize caps the bytes read from the remote messages-summary endpoint
|
||||
// to prevent a hostile server from exhausting memory via an unbounded response.
|
||||
const maxSummarySize = 20 * 1024 * 1024 // 20 MiB
|
||||
|
||||
// pageSize is the per-request limit when paging through the remote messages
|
||||
// summary endpoint.
|
||||
const pageSize = 10000
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
// idRe matches a valid Mailpit message ID (alphanumeric or dash, 8–60 chars).
|
||||
idRe = regexp.MustCompile(`^[a-zA-Z0-9-]{8,60}$`)
|
||||
|
||||
outDir string
|
||||
|
||||
// Base URL of mailpit instance
|
||||
@@ -29,13 +46,15 @@ var (
|
||||
// URL is the base URL of a remove Mailpit instance
|
||||
URL string
|
||||
|
||||
summary = []storage.MessageSummary{}
|
||||
dumpIDs = make(map[string]struct {
|
||||
Timestamp time.Time
|
||||
})
|
||||
)
|
||||
|
||||
// Sync will sync all messages from the specified database or API to the specified output directory
|
||||
func Sync(d string) error {
|
||||
|
||||
outDir = path.Clean(d)
|
||||
outDir = filepath.Clean(d)
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
@@ -71,53 +90,117 @@ func loadIDs() error {
|
||||
if base != "" {
|
||||
// remote
|
||||
logger.Log().Debugf("Fetching messages summary from %s", base)
|
||||
res, err := http.Get(base + "api/v1/messages?limit=0")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
start := 0
|
||||
var total uint64
|
||||
for {
|
||||
data, err := fetchSummaryPage(start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if start == 0 {
|
||||
total = data.Total
|
||||
}
|
||||
|
||||
for _, m := range data.Messages {
|
||||
dumpIDs[m.ID] = struct {
|
||||
Timestamp time.Time
|
||||
}{Timestamp: m.Created}
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetched messages summary page start=%d size=%d (%d/%d)", start, len(data.Messages), len(dumpIDs), total)
|
||||
|
||||
// stop on empty page to guard against stale/inconsistent Total
|
||||
if len(data.Messages) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if uint64(len(dumpIDs)) >= total {
|
||||
break
|
||||
}
|
||||
|
||||
start += pageSize
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary = data.Messages
|
||||
|
||||
} else {
|
||||
// make sure the database isn't pruned while open
|
||||
config.MaxMessages = 0
|
||||
|
||||
var err error
|
||||
// local database
|
||||
if err = storage.InitDB(); err != nil {
|
||||
if err := storage.InitDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
|
||||
|
||||
summary, err = storage.List(0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
start := 0
|
||||
for {
|
||||
page, err := storage.List(start, 0, pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range page {
|
||||
dumpIDs[m.ID] = struct {
|
||||
Timestamp time.Time
|
||||
}{Timestamp: m.Created}
|
||||
}
|
||||
|
||||
if len(page) < pageSize {
|
||||
break
|
||||
}
|
||||
|
||||
start += pageSize
|
||||
}
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
if len(dumpIDs) == 0 {
|
||||
return errors.New("no messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSummaryPage fetches a single page of the remote messages summary,
|
||||
// starting at the given offset.
|
||||
func fetchSummaryPage(start int) (*apiv1.MessagesSummary, error) {
|
||||
url := base + "api/v1/messages?limit=" + strconv.Itoa(pageSize) + "&start=" + strconv.Itoa(start)
|
||||
res, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("error fetching messages summary: HTTP " + res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, maxSummarySize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(len(body)) > maxSummarySize {
|
||||
return nil, errors.New("messages summary exceeds size cap")
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func saveMessages() error {
|
||||
for _, m := range summary {
|
||||
out := path.Join(outDir, m.ID+".eml")
|
||||
for id, m := range dumpIDs {
|
||||
if !idRe.MatchString(id) {
|
||||
logger.Log().Errorf("skipping message with invalid ID: %q", id)
|
||||
continue
|
||||
}
|
||||
|
||||
out := filepath.Join(outDir, id+".eml")
|
||||
|
||||
// skip if message exists
|
||||
if tools.IsFile(out) {
|
||||
@@ -126,37 +209,66 @@ func saveMessages() error {
|
||||
|
||||
var b []byte
|
||||
|
||||
limit := int64(config.MaxMessageSize) * 1024 * 1024
|
||||
|
||||
if base != "" {
|
||||
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
|
||||
res, err := httpClient.Get(base + "api/v1/message/" + id + "/raw")
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
b, err = io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
if res.StatusCode != http.StatusOK {
|
||||
res.Body.Close()
|
||||
logger.Log().Errorf("error fetching message %s: HTTP %d", id, res.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 {
|
||||
b, err = io.ReadAll(io.LimitReader(res.Body, limit+1))
|
||||
res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if int64(len(b)) > limit {
|
||||
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
b, err = io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
b, err = storage.GetMessageRaw(m.ID)
|
||||
b, err = storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 && int64(len(b)) > limit {
|
||||
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
|
||||
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error writing message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.Chtimes(out, m.Created, m.Created)
|
||||
_ = os.Chtimes(out, m.Timestamp, m.Timestamp)
|
||||
|
||||
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
|
||||
logger.Log().Debugf("Saved message %s to %s", id, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,7 +3,7 @@ package html2text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -30,18 +30,18 @@ func init() {
|
||||
}
|
||||
|
||||
// Strip will convert a HTML string to plain text
|
||||
func Strip(h string, includeLinks bool) string {
|
||||
func Strip(h string, includeLinks bool) (string, error) {
|
||||
h = spaceRe.ReplaceAllString(h, "</$1> <")
|
||||
h = brRe.ReplaceAllString(h, " ")
|
||||
h = imgRe.ReplaceAllString(h, " <$1")
|
||||
var buffer bytes.Buffer
|
||||
doc, err := html.Parse(strings.NewReader(h))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return "", fmt.Errorf("html2text: parsing HTML: %w", err)
|
||||
}
|
||||
|
||||
extract(doc, &buffer, includeLinks)
|
||||
return clean(buffer.String())
|
||||
return clean(buffer.String()), nil
|
||||
}
|
||||
|
||||
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
@@ -52,7 +52,8 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
}
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
if _, skip := skip[c.Data]; !skip {
|
||||
if _, shouldSkip := skip[c.Data]; !shouldSkip {
|
||||
extract(c, buff, includeLinks)
|
||||
if includeLinks && c.Data == "a" {
|
||||
for _, a := range c.Attr {
|
||||
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
|
||||
@@ -60,7 +61,6 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
extract(c, buff, includeLinks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestPlain(t *testing.T) {
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, false)
|
||||
res, _ := Strip(str, false)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
@@ -42,12 +42,12 @@ func TestWithLinks(t *testing.T) {
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1>
|
||||
<p>Paragraph</p>`] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text https://github.com"
|
||||
// broken html
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text. https://github.com"
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, true)
|
||||
res, _ := Strip(str, true)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2026-02-16 15:39:06 +0000",
|
||||
"last_update_date":"2026-05-13 14:45:41 +0000",
|
||||
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
|
||||
"data":[
|
||||
{
|
||||
@@ -75,10 +75,10 @@
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"keyframes",
|
||||
"last_test_date":"2023-12-19",
|
||||
"last_test_date":"2026-04-07",
|
||||
"test_url":"https://www.caniemail.com/tests/css-animation.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/u4oWccYOFNNyTagHs2NSUZqJYQ3MssrqDMocBnRa35hf7/list",
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2021-05":"a #1"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #1"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"a #2"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2021-05":"a #1"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"thunderbird":{"macos":{"78.10":"y","149.0.1":"n"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #1"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"a #2"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. Animation properties are supported but `@keyframes` are incorrectly prefixed.","2":"Partial. Only supports from and to keyframes. Does not support % keyframes"}
|
||||
},
|
||||
@@ -638,7 +638,7 @@
|
||||
"last_test_date":"2024-09-06",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
|
||||
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"y"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
|
||||
},
|
||||
@@ -718,7 +718,7 @@
|
||||
"last_test_date":"2024-04-25",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.","2":"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.","3":"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.","4":"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.","5":"Partial. Comment inside `style` attribute works.","6":"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed."}
|
||||
},
|
||||
@@ -782,7 +782,7 @@
|
||||
"last_test_date":"2021-11-02",
|
||||
"test_url":"https://www.caniemail.com/tests/css-flexbox.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/Kw9bvIPLsmmwVoXhbXpIu1FM31v4nV2KXMaEvPQPezSO9/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2020-11":"a #1"},"android":{"2019-02":"y","2020-11":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2021-11":"n","2024-04":"n"},"android":{"2019-08":"y","2021-11":"n","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"a","2020-11":"y"},"android":{"2019-02":"a","2020-11":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"aol":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2020-11":"a #1","2026-03":"a #1"},"android":{"2019-02":"y","2020-11":"a #1","2026-03":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2021-11":"n","2024-04":"n"},"android":{"2019-08":"y","2021-11":"n","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"a","2020-11":"y"},"android":{"2019-02":"a","2020-11":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"aol":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Not supported with non Google accounts.","2":"`display:inline-flex` is not supported."}
|
||||
},
|
||||
@@ -798,9 +798,9 @@
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2024-04":"n"},"android":{"2019-08":"y","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"n","2024-01":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"a #1"},"android":{"2019-02":"n","2026-03":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2024-04":"n"},"android":{"2019-08":"y","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"n","2024-01":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"aol":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
"notes_by_num":{"1":"Not supported with non Google accounts."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -931,6 +931,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-font-size-adjust",
|
||||
"title":"font-size-adjust",
|
||||
"description":"Adjusts the size of lowercase letters relative to the size of uppercase letters.",
|
||||
"url":"https://www.caniemail.com/features/css-font-size-adjust/",
|
||||
"category":"css",
|
||||
"tags":["accessibility"],
|
||||
"keywords":"font, adjust, fallback, font metric",
|
||||
"last_test_date":"2026-04-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-font-size-adjust.html",
|
||||
"test_results_url":"https://testi.at/proj/zn7d0pje1e3nsyz2i9",
|
||||
"stats":{"apple-mail":{"macos":{"11":"n","12":"n","13":"a #1","14":"y","15":"y","26":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"y","18":"y","26":"y"}},"gmail":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"a #2"},"android":{"2026-04":"a #2"},"mobile-webmail":{"2026-04":"y"}},"orange":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"n"},"macos":{"2026-04":"y"},"outlook-com":{"2026-04":"y"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"yahoo":{"desktop-webmail":{"2026-04":"a #3"},"ios":{"2026-04":"a #3"},"android":{"2026-04":"a #3"}},"aol":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"samsung-email":{"android":{"2026-04":"u"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"thunderbird":{"macos":{"149":"y"}},"protonmail":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"y"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"a #1"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"web-de":{"desktop-webmail":{"2026-04":"a #1"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
|
||||
"notes":"Rendering may depend on browser and OS support, Also applies to mobile client apps.",
|
||||
"notes_by_num":{"1":"Partial support. Supports one numerical value, 2-value font-metric syntax unsuported (ex-height, cap-height, ch-width, ic-width, ic-height).","2":"Not supported with non Google accounts.","3":"2-value syntax is supported, but the first value (font-metric) is ignored."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-font-size",
|
||||
"title":"font-size",
|
||||
@@ -942,7 +958,7 @@
|
||||
"last_test_date":"2024-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-font-size.html",
|
||||
"test_results_url":"https://testi.at/proj/vr3ai85bunngsxjjfd2",
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"},"mobile-webmail":{"2024-02":"y"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2024-02":"a #2"},"macos":{"2024-02":"y"},"outlook-com":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"yahoo":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"aol":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"samsung-email":{"android":{"2024-02":"a #2"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"protonmail":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}},"gmx":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"},"mobile-webmail":{"2024-02":"y"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2024-02":"a #2"},"macos":{"2024-02":"y"},"outlook-com":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"yahoo":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"aol":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"samsung-email":{"android":{"2024-02":"a #2"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"protonmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}},"gmx":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial support. `rem` values are not supported.","2":"Partial support. `relative` and `percentage` size values not supported."}
|
||||
},
|
||||
@@ -1171,6 +1187,22 @@
|
||||
"notes_by_num":{"1":"Partial. Only supported in lowercase. (See [email-bugs#13](https://github.com/hteumeuleu/email-bugs/issues/13))","2":"Partial. Only supported inline when using a Non Gmail Account due to the lack of `<style>` support.","3":"Not supported. The entire declaration is removed if there is no space before `!important`.","4":"Partial. Only supported with a space before.","5":"Partial. Not supported inline. (See [email-bugs#31](https://github.com/hteumeuleu/email-bugs/issues/31))","6":"Partial. Only supported inline due to the lack of `<style>` support.","7":"Partial. Removed if there is no space before when used with a `background-image` property. (See [email-bugs#16](https://github.com/hteumeuleu/email-bugs/issues/16))","8":"Partial. Only supported in lowercase.","9":"Partial. Only supported if not written in lowercase."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-inert-attribute",
|
||||
"title":"inert",
|
||||
"description":"This attribute should render elements inactive",
|
||||
"url":"https://www.caniemail.com/features/css-inert-attribute/",
|
||||
"category":"css",
|
||||
"tags":["accessibility"],
|
||||
"keywords":"focus, inactive",
|
||||
"last_test_date":"2026-04-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-inert-attribute.html",
|
||||
"test_results_url":"https://testi.at/proj/eg6o04ae0kv6t3ek0px",
|
||||
"stats":{"apple-mail":{"macos":{"2026-04":"y"},"ios":{"2026-04":"y"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"n"},"macos":{"2026-04":"n"},"outlook-com":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"aol":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"samsung-email":{"android":{"2026-04":"y"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-inline-size",
|
||||
"title":"inline-size ",
|
||||
@@ -1907,6 +1939,38 @@
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-pseudo-class-focus-visible",
|
||||
"title":":focus-visible",
|
||||
"description":null,
|
||||
"url":"https://www.caniemail.com/features/css-pseudo-class-focus-visible/",
|
||||
"category":"css",
|
||||
"tags":["accessibility"],
|
||||
"keywords":"pseudo-class, focus",
|
||||
"last_test_date":"2026-04-14",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-class-focus.html",
|
||||
"test_results_url":"https://testi.at/proj/pv47sy35udb30ae816",
|
||||
"stats":{"apple-mail":{"macos":{"2026-04":"y #3"},"ios":{"2026-04":"u"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"u"},"macos":{"2026-04":"a #1"},"outlook-com":{"2026-04":"a #1"},"ios":{"2026-04":"u"},"android":{"2026-04":"a #1"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"aol":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"samsung-email":{"android":{"2026-04":"y #2"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors.","2":"Input with type text is not focusable at all","3":"Button is not focusable on tab"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-pseudo-class-focus-within",
|
||||
"title":":focus-within",
|
||||
"description":null,
|
||||
"url":"https://www.caniemail.com/features/css-pseudo-class-focus-within/",
|
||||
"category":"css",
|
||||
"tags":["accessibility"],
|
||||
"keywords":"pseudo-class, focus",
|
||||
"last_test_date":"2026-04-14",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-class-focus.html",
|
||||
"test_results_url":"https://testi.at/proj/pv47sy35udb30ae816",
|
||||
"stats":{"apple-mail":{"macos":{"2026-04":"y"},"ios":{"2026-04":"u"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"u"},"macos":{"2026-04":"a #1"},"outlook-com":{"2026-04":"n"},"ios":{"2026-04":"u"},"android":{"2026-04":"a #1"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"aol":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"samsung-email":{"android":{"2026-04":"y"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-pseudo-class-focus",
|
||||
"title":":focus",
|
||||
@@ -2619,12 +2683,12 @@
|
||||
"category":"css",
|
||||
"tags":["i18n"],
|
||||
"keywords":"align",
|
||||
"last_test_date":"2021-09-24",
|
||||
"last_test_date":"2025-11-14",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-align.html",
|
||||
"test_results_url":"https://testi.at/proj/G4YtBn8fBxEsLx6uybqcxD",
|
||||
"stats":{"apple-mail":{"macos":{"2021-09":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"a #2"},"android":{"2021-09":"a #2"},"mobile-webmail":{"2021-09":"y"}},"orange":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"outlook":{"windows":{"2007":"a #1","2010":"a #1","2013":"a #1","2016":"a #1","2019":"a #1"},"windows-mail":{"2021-09":"a #1"},"macos":{"2021-09":"y","16.80":"y"},"outlook-com":{"2021-09":"y","2024-01":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"yahoo":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"6.37":"a #1"}},"aol":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"sfr":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"thunderbird":{"macos":{"2021-09":"y"}},"protonmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"hey":{"desktop-webmail":{"2021-09":"y"}},"mail-ru":{"desktop-webmail":{"2021-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-09":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y #1"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y #1"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"test_results_url":"https://testi.at/proj/j39ys8ybfbr8srez1ab",
|
||||
"stats":{"apple-mail":{"macos":{"2021-09":"y","2025-11":"y"},"ios":{"11":"a #3 #5","12":"a #3 #5","13":"a #3 #5","14":"a #3 #5","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2021-09":"y","2025-11":"y #4"},"ios":{"2021-09":"a #2","2025-11":"y"},"android":{"2021-09":"a #2","2025-11":"a #3 #5"},"mobile-webmail":{"2021-09":"y","2025-11":"a #3 #5"}},"orange":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"outlook":{"windows":{"2007":"a #1 #3","2010":"a #1 #3","2013":"a #1 #3","2016":"a #1 #3","2019":"a #1 #3"},"windows-mail":{"2021-09":"a #1 #3"},"macos":{"2021-09":"y","16.80":"y","16.103":"y"},"outlook-com":{"2021-09":"y","2024-01":"y","2025-11":"y #4"},"ios":{"2021-09":"y"},"android":{"2021-09":"y","5.2543.1":"a #3"}},"yahoo":{"desktop-webmail":{"2021-09":"a #1","2025-11":"a #1 #3"},"ios":{"2021-09":"a #1","2025-11":"a #1 #3"},"android":{"6.37":"a #1","7.74":"a #1 #3"}},"aol":{"desktop-webmail":{"2021-09":"a #1","2025-11":"a #1 #3"},"ios":{"2021-09":"a #1","2025-11":"a #1 #3"},"android":{"2021-09":"a #1","2025-11":"a #1 #3"}},"samsung-email":{"android":{"6.1.51.1":"a #3 #5","6.2.06.0":"a #3 #5"}},"sfr":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"thunderbird":{"macos":{"2021-09":"y","2025-11":"y"}},"protonmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"hey":{"desktop-webmail":{"2021-09":"y"}},"mail-ru":{"desktop-webmail":{"2021-09":"y","2025-11":"y #4"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-09":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y #1","2025-11":"a #1 #3"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y #1","2025-11":"a #1 #3"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Flow-relative values `start` and `end` are not supported.","2":"Partial. Flow-relative values `start` and `end` are not supported with non Gmail account."}
|
||||
"notes_by_num":{"1":"Partial. Flow-relative values `start` and `end` are not supported.","2":"Partial. Flow-relative values `start` and `end` are not supported with non Gmail account.","3":"Partial. Doesn't support the `match-parent` value.","4":"Support of the `match-parent` value depends on browser support.","5":"Supports the vendor prefixed value `-webkit-match-parent`."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -2766,7 +2830,7 @@
|
||||
"last_test_date":"2021-01-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-indent.html",
|
||||
"test_results_url":"https://testi.at/proj/Ew5f99Cy8NuRM0iPMVFoyYI8",
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"2021-01":"a #1"},"android":{"2021-01":"a #1"},"mobile-webmail":{"2021-01":"a #1"}},"orange":{"desktop-webmail":{"2021-01":"y","2021-03":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-01":"y"},"macos":{"2021-01":"y","16.80":"y"},"outlook-com":{"2021-01":"y","2024-01":"y"},"ios":{"2021-01":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.21.1":"a #1"},"android":{"6.16.2.1525679":"a #1"}},"aol":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.0.0":"a #1"},"android":{"5.15.0":"a #1"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"sfr":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"thunderbird":{"macos":{"2021-01":"y"}},"protonmail":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"hey":{"desktop-webmail":{"2021-01":"y"}},"mail-ru":{"desktop-webmail":{"2021-01":"a #2"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"2021-01":"a #1"},"android":{"2021-01":"a #1"},"mobile-webmail":{"2021-01":"a #1"}},"orange":{"desktop-webmail":{"2021-01":"y","2021-03":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-01":"y"},"macos":{"2021-01":"y","16.80":"y"},"outlook-com":{"2021-01":"y","2024-01":"y"},"ios":{"2021-01":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.21.1":"a #1"},"android":{"6.16.2.1525679":"a #1"}},"aol":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.0.0":"a #1"},"android":{"5.15.0":"a #1"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"sfr":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"thunderbird":{"macos":{"2021-01":"y"}},"protonmail":{"desktop-webmail":{"2021-01":"a #2"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"hey":{"desktop-webmail":{"2021-01":"y"}},"mail-ru":{"desktop-webmail":{"2021-01":"a #2"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported."}
|
||||
},
|
||||
@@ -2782,7 +2846,7 @@
|
||||
"last_test_date":"2024-04-17",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-justify.html",
|
||||
"test_results_url":"https://testi.at/proj/z7b61px4fel2ivk9sb2",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `text-justify` is stripped","2":"Partial. Depends on browser support","3":"Partial. `text-justify` is stripped except when the value is `inter-character`","4":"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`","5":"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`"}
|
||||
},
|
||||
@@ -2894,7 +2958,7 @@
|
||||
"last_test_date":"2024-04-03",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-wrap.html",
|
||||
"test_results_url":"https://testi.at/proj/xle5u5a5i9eh9opi7a",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"15":"y","14":"y"}},"gmail":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"},"mobile-webmail":{"2024-04":"n"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-04":"n"},"macos":{"2024-04":"n"},"outlook-com":{"2024-04":"n","2024-01":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"yahoo":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"aol":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"15":"y","14":"y"}},"gmail":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"},"mobile-webmail":{"2024-04":"n"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-04":"n"},"macos":{"2024-04":"n"},"outlook-com":{"2024-04":"n","2024-01":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"yahoo":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"aol":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -3486,7 +3550,7 @@
|
||||
"last_test_date":"2019-08-08",
|
||||
"test_url":"https://www.caniemail.com/tests/html-anchor-links.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/lvP3Vdg0qtue1RAuGTjzEXl19nfCJu3TVV4lLdzwdqQk5/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y #6"},"ios":{"12.4":"n #3","15.0":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y #7"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"y #6"},"mobile-webmail":{"2020-02":"y #7"}},"orange":{"desktop-webmail":{"2019-08":"a #1","2021-03":"n","2024-04":"n #8"},"ios":{"2019-08":"n #3","2024-04":"n"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"u","2007":"y #7","2010":"y #7","2013":"y #7","2016":"y #7","2019":"y #7"},"windows-mail":{"2020-01":"y #7"},"macos":{"2019":"n","2023":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"n #3"}},"samsung-email":{"android":{"6.0":"n #3"}},"sfr":{"desktop-webmail":{"2019-08":"n #2"},"ios":{"2019-08":"n #4"},"android":{"2019-08":"n #3"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"a #7"},"ios":{"2020-01":"y"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"a #7"},"ios":{"2019-08":"n #5"},"android":{"2019-08":"n #3"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #7 #9"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y #6"},"ios":{"12.4":"n #3","15.0":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y #7"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"y #6"},"mobile-webmail":{"2020-02":"y #7"}},"orange":{"desktop-webmail":{"2019-08":"a #1","2021-03":"n","2024-04":"n #8"},"ios":{"2019-08":"n #3","2024-04":"n"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"u","2007":"y #7","2010":"y #7","2013":"y #7","2016":"y #7","2019":"y #7"},"windows-mail":{"2020-01":"y #7"},"macos":{"2019":"n","2023":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"n #3","2026-05":"y"},"android":{"2019-08":"n #3","2026-05":"y"}},"samsung-email":{"android":{"6.0":"n #3"}},"sfr":{"desktop-webmail":{"2019-08":"n #2"},"ios":{"2019-08":"n #4"},"android":{"2019-08":"n #3"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"a #7"},"ios":{"2020-01":"y"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"a #7"},"ios":{"2019-08":"n #5"},"android":{"2019-08":"n #3"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #7 #9"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `target=_blank` is added on links so anchors open in a new window.","2":"Buggy. Anchor links go back to the homepage of the webmail because it also uses anchor links for navigation.","3":"Buggy. Clicking an anchor link does nothing.","4":"Buggy. Opens a new browser window with the anchor as a URL.","5":"Buggy. Opens a new in-app browser window on yahoo.com with the anchor appended to the URL.","6":"Buggy. Targeted content is partially hidden by the application UI on top.","7":"Partial. Works when targeting an empty anchor with the corresponding `name` attribute, but not with `id` attributes.","8":"Not supported. Opens a new window with the same email.","9":"The `name` and `href` attributes are prefixed by a specific `mailruanchor_` prefix."}
|
||||
},
|
||||
@@ -3758,7 +3822,7 @@
|
||||
"last_test_date":"2024-05-1",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
// It requires the HTTP response code to be 200 and the content-type to be text/css.
|
||||
// It will download a maximum of 5MB.
|
||||
func downloadCSSToBytes(url string) ([]byte, error) {
|
||||
client := newSafeHTTPClient()
|
||||
client := safeHTTPClient()
|
||||
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -272,17 +272,15 @@ func testInlineStyles(doc *goquery.Document) map[string]int {
|
||||
return matches
|
||||
}
|
||||
|
||||
func newSafeHTTPClient() *http.Client {
|
||||
func safeHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: nil, // avoid env proxy surprises unless you explicitly want it
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
},
|
||||
Proxy: nil, // avoid env proxy surprises unless you explicitly want it
|
||||
DialContext: safeDialContext(dialer),
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
@@ -308,3 +306,29 @@ func newSafeHTTPClient() *http.Client {
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// safeDialContext is the same pattern as linkcheck/status.go::safeDialContext
|
||||
// — copy the function (or factor a shared helper into internal/tools/net.go).
|
||||
func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !config.AllowInternalHTTPRequests {
|
||||
for _, ip := range ips {
|
||||
if tools.IsInternalIP(ip.IP) {
|
||||
return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,177 @@
|
||||
// Package logger handles the logging
|
||||
// Mailpit now uses slog for logging, but this package provides a logrus-compatible API and formatting to avoid changing all existing log calls
|
||||
// and provide backwards compatibility with logrus formatting and features like log levels and file output.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Logger wraps slog.Logger providing a logrus-compatible API
|
||||
type Logger struct {
|
||||
sl *slog.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
log *logrus.Logger
|
||||
log *Logger
|
||||
// VerboseLogging for verbose logging
|
||||
VerboseLogging bool
|
||||
// QuietLogging shows only errors
|
||||
QuietLogging bool
|
||||
// NoLogging shows only fatal errors
|
||||
// NoLogging disables all logging (tests)
|
||||
NoLogging bool
|
||||
// LogFile sets a log file
|
||||
LogFile string
|
||||
)
|
||||
|
||||
// Log returns the logger instance
|
||||
func Log() *logrus.Logger {
|
||||
// Log returns the logger instance, initialising it on first call. The level and
|
||||
// output destination are determined once from VerboseLogging, QuietLogging,
|
||||
// NoLogging, and LogFile at the time of first use.
|
||||
func Log() *Logger {
|
||||
if log == nil {
|
||||
log = logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
if VerboseLogging {
|
||||
// verbose logging (debug)
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
} else if QuietLogging {
|
||||
// show errors only
|
||||
log.SetLevel(logrus.ErrorLevel)
|
||||
} else if NoLogging {
|
||||
// disable all logging (tests)
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case VerboseLogging:
|
||||
level = slog.LevelDebug
|
||||
case QuietLogging:
|
||||
level = slog.LevelError
|
||||
case NoLogging:
|
||||
level = slog.Level(100) // above all real levels — silences all output
|
||||
}
|
||||
|
||||
out := os.Stdout
|
||||
if LogFile != "" {
|
||||
file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec
|
||||
if err == nil {
|
||||
log.Out = file
|
||||
out = file
|
||||
} else {
|
||||
log.Out = os.Stdout
|
||||
log.Warn("Failed to log to file, using default stderr")
|
||||
fmt.Fprintln(os.Stderr, "failed to log to file, using default stdout")
|
||||
}
|
||||
} else {
|
||||
log.Out = os.Stdout
|
||||
}
|
||||
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006/01/02 15:04:05",
|
||||
})
|
||||
log = &Logger{
|
||||
sl: slog.New(&logrusHandler{
|
||||
out: out,
|
||||
level: level,
|
||||
color: isTerminal(out),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// PrettyPrint for debugging
|
||||
// logrusHandler is a slog.Handler that formats output to match logrus TextFormatter.
|
||||
// TTY output: INFO[2006/01/02 15:04:05] message
|
||||
// File output: time="2006/01/02 15:04:05" level=info msg="message"
|
||||
type logrusHandler struct {
|
||||
mu sync.Mutex
|
||||
out *os.File
|
||||
level slog.Level
|
||||
color bool
|
||||
}
|
||||
|
||||
// Enabled reports whether the handler will emit a record at the given level.
|
||||
func (h *logrusHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
// Handle formats and writes a log record. TTY output is coloured; file output
|
||||
// uses the logrus key=value text format.
|
||||
func (h *logrusHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
label, name, code := logrusLevel(r.Level)
|
||||
ts := r.Time.Format("2006/01/02 15:04:05")
|
||||
|
||||
var line string
|
||||
if h.color {
|
||||
line = fmt.Sprintf("\x1b[%dm%s\x1b[0m[%s] %s\n", code, label, ts, r.Message)
|
||||
} else {
|
||||
line = fmt.Sprintf("time=%q level=%s msg=%q\n", ts, name, r.Message)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, err := fmt.Fprint(h.out, line)
|
||||
return err
|
||||
}
|
||||
|
||||
// WithAttrs returns the handler unchanged; structured attributes are not used.
|
||||
func (h *logrusHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
|
||||
|
||||
// WithGroup returns the handler unchanged; groups are not used.
|
||||
func (h *logrusHandler) WithGroup(_ string) slog.Handler { return h }
|
||||
|
||||
// logrusLevel maps slog levels to the 4-char TTY label, lowercase file label, and ANSI colour code.
|
||||
func logrusLevel(level slog.Level) (string, string, int) {
|
||||
switch {
|
||||
case level < slog.LevelInfo:
|
||||
return "DEBU", "debug", 37 // gray
|
||||
case level < slog.LevelWarn:
|
||||
return "INFO", "info", 36 // cyan
|
||||
case level < slog.LevelError:
|
||||
return "WARN", "warning", 33 // yellow
|
||||
default:
|
||||
return "ERRO", "error", 31 // red
|
||||
}
|
||||
}
|
||||
|
||||
// isTerminal reports whether f is connected to a terminal.
|
||||
func isTerminal(f *os.File) bool {
|
||||
info, err := f.Stat()
|
||||
return err == nil && info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// Info logs a message at INFO level.
|
||||
func (l *Logger) Info(args ...any) { l.sl.Info(fmt.Sprint(args...)) }
|
||||
|
||||
// Infof logs a formatted message at INFO level.
|
||||
func (l *Logger) Infof(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) }
|
||||
|
||||
// Debug logs a message at DEBUG level.
|
||||
func (l *Logger) Debug(args ...any) { l.sl.Debug(fmt.Sprint(args...)) }
|
||||
|
||||
// Debugf logs a formatted message at DEBUG level.
|
||||
func (l *Logger) Debugf(format string, args ...any) { l.sl.Debug(fmt.Sprintf(format, args...)) }
|
||||
|
||||
// Warn logs a message at WARN level.
|
||||
func (l *Logger) Warn(args ...any) { l.sl.Warn(fmt.Sprint(args...)) }
|
||||
|
||||
// Warnf logs a formatted message at WARN level.
|
||||
func (l *Logger) Warnf(format string, args ...any) { l.sl.Warn(fmt.Sprintf(format, args...)) }
|
||||
|
||||
// Error logs a message at ERROR level.
|
||||
func (l *Logger) Error(args ...any) { l.sl.Error(fmt.Sprint(args...)) }
|
||||
|
||||
// Errorf logs a formatted message at ERROR level.
|
||||
func (l *Logger) Errorf(format string, args ...any) { l.sl.Error(fmt.Sprintf(format, args...)) }
|
||||
|
||||
// Printf logs a formatted message at INFO level.
|
||||
func (l *Logger) Printf(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) }
|
||||
|
||||
// Fatal logs a message at ERROR level then exits with status 1.
|
||||
func (l *Logger) Fatal(args ...any) { l.sl.Error(fmt.Sprint(args...)); os.Exit(1) }
|
||||
|
||||
// Fatalf logs a formatted message at ERROR level then exits with status 1.
|
||||
func (l *Logger) Fatalf(format string, args ...any) {
|
||||
l.sl.Error(fmt.Sprintf(format, args...))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// PrettyPrint prints any value as indented JSON to stdout, for debugging.
|
||||
func PrettyPrint(i any) {
|
||||
s, _ := json.MarshalIndent(i, "", "\t")
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
|
||||
// CleanHTTPIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
// CleanHTTPIP returns a human-readable address for log output.
|
||||
// It translates [::]:<port> to localhost:<port>.
|
||||
func CleanHTTPIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
|
||||
@@ -2,138 +2,171 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Registry is the Prometheus registry for Mailpit metrics
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Metrics
|
||||
totalMessages prometheus.Gauge
|
||||
unreadMessages prometheus.Gauge
|
||||
databaseSize prometheus.Gauge
|
||||
messagesDeleted prometheus.Counter
|
||||
smtpAccepted prometheus.Counter
|
||||
smtpRejected prometheus.Counter
|
||||
smtpIgnored prometheus.Counter
|
||||
smtpAcceptedSize prometheus.Counter
|
||||
uptime prometheus.Gauge
|
||||
memoryUsage prometheus.Gauge
|
||||
tagCounters *prometheus.GaugeVec
|
||||
)
|
||||
|
||||
// InitMetrics initializes all Prometheus metrics
|
||||
func initMetrics() {
|
||||
// Create metrics
|
||||
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages",
|
||||
Help: "Total number of messages in the database",
|
||||
})
|
||||
|
||||
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages_unread",
|
||||
Help: "Number of unread messages in the database",
|
||||
})
|
||||
|
||||
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_database_size_bytes",
|
||||
Help: "Size of the database in bytes",
|
||||
})
|
||||
|
||||
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_messages_deleted_total",
|
||||
Help: "Total number of messages deleted",
|
||||
})
|
||||
|
||||
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_total",
|
||||
Help: "Total number of SMTP messages accepted",
|
||||
})
|
||||
|
||||
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_rejected_total",
|
||||
Help: "Total number of SMTP messages rejected",
|
||||
})
|
||||
|
||||
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_ignored_total",
|
||||
Help: "Total number of SMTP messages ignored (duplicates)",
|
||||
})
|
||||
|
||||
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_size_bytes_total",
|
||||
Help: "Total size of accepted SMTP messages in bytes",
|
||||
})
|
||||
|
||||
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_uptime_seconds",
|
||||
Help: "Uptime of Mailpit in seconds",
|
||||
})
|
||||
|
||||
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_memory_usage_bytes",
|
||||
Help: "Memory usage in bytes",
|
||||
})
|
||||
|
||||
tagCounters = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "mailpit_tag_messages",
|
||||
Help: "Number of messages per tag",
|
||||
},
|
||||
[]string{"tag"},
|
||||
)
|
||||
|
||||
// Register metrics
|
||||
Registry.MustRegister(totalMessages)
|
||||
Registry.MustRegister(unreadMessages)
|
||||
Registry.MustRegister(databaseSize)
|
||||
Registry.MustRegister(messagesDeleted)
|
||||
Registry.MustRegister(smtpAccepted)
|
||||
Registry.MustRegister(smtpRejected)
|
||||
Registry.MustRegister(smtpIgnored)
|
||||
Registry.MustRegister(smtpAcceptedSize)
|
||||
Registry.MustRegister(uptime)
|
||||
Registry.MustRegister(memoryUsage)
|
||||
Registry.MustRegister(tagCounters)
|
||||
type gauge struct {
|
||||
mu sync.RWMutex
|
||||
val float64
|
||||
}
|
||||
|
||||
func (g *gauge) Set(v float64) {
|
||||
g.mu.Lock()
|
||||
g.val = v
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *gauge) get() float64 {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.val
|
||||
}
|
||||
|
||||
type gaugeVec struct {
|
||||
mu sync.RWMutex
|
||||
label string
|
||||
vals map[string]float64
|
||||
}
|
||||
|
||||
func newGaugeVec(label string) *gaugeVec {
|
||||
return &gaugeVec{label: label, vals: make(map[string]float64)}
|
||||
}
|
||||
|
||||
func (v *gaugeVec) Set(labelVal string, val float64) {
|
||||
v.mu.Lock()
|
||||
v.vals[labelVal] = val
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
func (v *gaugeVec) Reset() {
|
||||
v.mu.Lock()
|
||||
v.vals = make(map[string]float64)
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
help string
|
||||
typ string
|
||||
g *gauge
|
||||
vec *gaugeVec
|
||||
}
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
registry []entry
|
||||
|
||||
totalMessages = &gauge{}
|
||||
unreadMessages = &gauge{}
|
||||
databaseSize = &gauge{}
|
||||
messagesDeleted = &gauge{}
|
||||
smtpAccepted = &gauge{}
|
||||
smtpRejected = &gauge{}
|
||||
smtpIgnored = &gauge{}
|
||||
smtpAcceptedSize = &gauge{}
|
||||
uptime = &gauge{}
|
||||
memoryUsage = &gauge{}
|
||||
tagCounters = newGaugeVec("tag")
|
||||
)
|
||||
|
||||
func register(name, help, typ string, g *gauge, vec *gaugeVec) {
|
||||
regMu.Lock()
|
||||
registry = append(registry, entry{name: name, help: help, typ: typ, g: g, vec: vec})
|
||||
regMu.Unlock()
|
||||
}
|
||||
|
||||
func initMetrics() {
|
||||
register("mailpit_database_size_bytes", "Size of the database in bytes", "gauge", databaseSize, nil)
|
||||
register("mailpit_memory_usage_bytes", "Memory usage in bytes", "gauge", memoryUsage, nil)
|
||||
register("mailpit_messages", "Total number of messages in the database", "gauge", totalMessages, nil)
|
||||
register("mailpit_messages_deleted_total", "Total number of messages deleted", "counter", messagesDeleted, nil)
|
||||
register("mailpit_messages_unread", "Number of unread messages in the database", "gauge", unreadMessages, nil)
|
||||
register("mailpit_smtp_accepted_size_bytes_total", "Total size of accepted SMTP messages in bytes", "counter", smtpAcceptedSize, nil)
|
||||
register("mailpit_smtp_accepted_total", "Total number of SMTP messages accepted", "counter", smtpAccepted, nil)
|
||||
register("mailpit_smtp_ignored_total", "Total number of SMTP messages ignored (duplicates)", "counter", smtpIgnored, nil)
|
||||
register("mailpit_smtp_rejected_total", "Total number of SMTP messages rejected", "counter", smtpRejected, nil)
|
||||
register("mailpit_tag_messages", "Number of messages per tag", "gauge", nil, tagCounters)
|
||||
register("mailpit_uptime_seconds", "Uptime of Mailpit in seconds", "gauge", uptime, nil)
|
||||
}
|
||||
|
||||
// UpdateMetrics updates all metrics with current values
|
||||
func updateMetrics() {
|
||||
info := stats.Load(false)
|
||||
|
||||
totalMessages.Set(float64(info.Messages))
|
||||
unreadMessages.Set(float64(info.Unread))
|
||||
databaseSize.Set(float64(info.DatabaseSize))
|
||||
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
messagesDeleted.Set(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Set(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Set(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Set(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Set(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
uptime.Set(float64(info.RuntimeStats.Uptime))
|
||||
memoryUsage.Set(float64(info.RuntimeStats.Memory))
|
||||
|
||||
// Reset tag counters
|
||||
tagCounters.Reset()
|
||||
|
||||
// Update tag counters
|
||||
for tag, count := range info.Tags {
|
||||
tagCounters.WithLabelValues(tag).Set(float64(count))
|
||||
tagCounters.Set(tag, float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus handler & disables double compression in middleware
|
||||
func writeMetrics(w io.Writer) {
|
||||
regMu.RLock()
|
||||
entries := make([]entry, len(registry))
|
||||
copy(entries, registry)
|
||||
regMu.RUnlock()
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].name < entries[j].name
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n", e.name, e.help, e.name, e.typ)
|
||||
if e.g != nil {
|
||||
fmt.Fprintf(w, "%s %s\n", e.name, formatFloat(e.g.get()))
|
||||
} else {
|
||||
e.vec.mu.RLock()
|
||||
keys := make([]string, 0, len(e.vec.vals))
|
||||
snapshot := make(map[string]float64, len(e.vec.vals))
|
||||
for k, v := range e.vec.vals {
|
||||
keys = append(keys, k)
|
||||
snapshot[k] = v
|
||||
}
|
||||
e.vec.mu.RUnlock()
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, "%s{%s=\"%s\"} %s\n", e.name, e.vec.label, escapeLabelValue(k), formatFloat(snapshot[k]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func escapeLabelValue(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus metrics HTTP handler
|
||||
func GetHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
writeMetrics(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,11 +175,9 @@ func StartUpdater() {
|
||||
initMetrics()
|
||||
updateMetrics()
|
||||
|
||||
// Start periodic updates
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
updateMetrics()
|
||||
}
|
||||
@@ -159,18 +190,15 @@ func StartSeparateServer() {
|
||||
|
||||
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
|
||||
|
||||
// Create a dedicated mux for the metrics server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||
mux.Handle("/metrics", GetHandler())
|
||||
|
||||
// Create a dedicated server instance
|
||||
server := &http.Server{
|
||||
Addr: config.PrometheusListen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
|
||||
}
|
||||
@@ -179,7 +207,6 @@ func StartSeparateServer() {
|
||||
// GetMode returns the Prometheus run mode
|
||||
func GetMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
|
||||
|
||||
switch mode {
|
||||
case "false", "":
|
||||
return "disabled"
|
||||
|
||||
53
internal/shortuuid/shortuuid.go
Normal file
53
internal/shortuuid/shortuuid.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package shortuuid provides a simple way to generate short, unique, alphanumeric identifiers.
|
||||
// The generated IDs are 22 characters long and consist of uppercase letters, lowercase letters, and digits.
|
||||
package shortuuid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/bits"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
length = 22
|
||||
nDigits = 10
|
||||
divisor = 839299365868340224 // 62^10, max power of 62 that fits in uint64
|
||||
)
|
||||
|
||||
// New returns a 22-character alphanumeric unique identifier.
|
||||
func New() string {
|
||||
id := uuid.New()
|
||||
num := [2]uint64{
|
||||
binary.BigEndian.Uint64(id[8:]),
|
||||
binary.BigEndian.Uint64(id[:8]),
|
||||
}
|
||||
|
||||
buf := make([]byte, length)
|
||||
var r uint64
|
||||
i := length - 1
|
||||
for num[1] > 0 || num[0] > 0 {
|
||||
num, r = quoRem64(num, divisor)
|
||||
for j := 0; j < nDigits && i >= 0; j++ {
|
||||
buf[i] = alphabet[r%62]
|
||||
r /= 62
|
||||
i--
|
||||
}
|
||||
}
|
||||
for ; i >= 0; i-- {
|
||||
buf[i] = alphabet[0]
|
||||
}
|
||||
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// quoRem64 divides a 128-bit number (represented as [lo, hi] uint64) by v,
|
||||
// returning the quotient and remainder.
|
||||
func quoRem64(u [2]uint64, v uint64) ([2]uint64, uint64) {
|
||||
var q [2]uint64
|
||||
var r uint64
|
||||
q[1], r = bits.Div64(0, u[1], v)
|
||||
q[0], r = bits.Div64(r, u[0], v)
|
||||
return q, r
|
||||
}
|
||||
52
internal/shortuuid/shortuuid_test.go
Normal file
52
internal/shortuuid/shortuuid_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package shortuuid
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// alphanumeric matches IDs that contain only digits and ASCII letters.
|
||||
var alphanumeric = regexp.MustCompile(`^[0-9A-Za-z]+$`)
|
||||
|
||||
// TestLength verifies that every generated ID is exactly 22 characters long,
|
||||
// including when the UUID encodes to a value with leading zero-padding.
|
||||
func TestLength(t *testing.T) {
|
||||
for range 100 {
|
||||
id := New()
|
||||
if len(id) != length {
|
||||
t.Errorf("expected length %d, got %d: %q", length, len(id), id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphanumeric verifies that no ID contains hyphens, underscores, or any
|
||||
// other non-alphanumeric character that would be unsafe in a URL path segment.
|
||||
func TestAlphanumeric(t *testing.T) {
|
||||
for range 100 {
|
||||
id := New()
|
||||
if !alphanumeric.MatchString(id) {
|
||||
t.Errorf("non-alphanumeric characters in ID: %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnique verifies that IDs are unique across a large sample. Collisions are
|
||||
// cryptographically implausible given the 122-bit UUID entropy, so any hit here
|
||||
// indicates a bug in the encoding (e.g. truncation, constant output).
|
||||
func TestUnique(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 1000000)
|
||||
for range 1000000 {
|
||||
id := New()
|
||||
if _, exists := seen[id]; exists {
|
||||
t.Fatalf("duplicate ID generated: %q", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNew measures the cost of generating a single ID, including UUID generation.
|
||||
func BenchmarkNew(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = New()
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func autoForwardMessage(from string, data *[]byte) error {
|
||||
}
|
||||
|
||||
if err := forward(from, *data); err != nil {
|
||||
return errors.WithMessage(err, "[forward] error: %s")
|
||||
return fmt.Errorf("[forward] error: %w", err)
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
@@ -59,6 +59,7 @@ func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr stri
|
||||
// Set the hostname for HELO/EHLO
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if err := client.Hello(hostname); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/shortuuid"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -247,6 +247,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
},
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 {
|
||||
srv.MaxSize = config.MaxMessageSize * 1024 * 1024
|
||||
}
|
||||
|
||||
if config.Label != "" {
|
||||
srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label)
|
||||
}
|
||||
|
||||
@@ -639,6 +639,9 @@ loop:
|
||||
xCArgs := strings.SplitSeq(args, " ")
|
||||
for xCArg := range xCArgs {
|
||||
xCParse := strings.Split(strings.TrimSpace(xCArg), "=")
|
||||
if len(xCParse) != 2 {
|
||||
continue
|
||||
}
|
||||
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
|
||||
s.xClientADDR = xCParse[1]
|
||||
}
|
||||
@@ -879,10 +882,14 @@ func (s *session) readData() ([]byte, error) {
|
||||
// TODO: Work out what to do with multiple to addresses.
|
||||
func (s *session) makeHeaders(to []string) []byte {
|
||||
var buffer bytes.Buffer
|
||||
if len(to) == 0 {
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)")
|
||||
buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP))
|
||||
buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName))
|
||||
buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now))
|
||||
fmt.Fprintf(&buffer, "Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP)
|
||||
fmt.Fprintf(&buffer, " by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName)
|
||||
fmt.Fprintf(&buffer, " for <%s>; %s\r\n", to[0], now)
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
@@ -64,6 +63,7 @@ func pruneMessages() {
|
||||
start := time.Now()
|
||||
|
||||
ids := []string{}
|
||||
idsSeen := make(map[string]bool)
|
||||
var prunedSize uint64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
@@ -88,6 +88,7 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
idsSeen[id] = true
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
|
||||
},
|
||||
@@ -115,8 +116,9 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
if _, exists := idsSeen[id]; !exists {
|
||||
ids = append(ids, id)
|
||||
idsSeen[id] = true
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/shortuuid"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// Store will save an email to the database tables.
|
||||
@@ -278,8 +278,19 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
}
|
||||
|
||||
// set tags for listed messages only
|
||||
for i, m := range results {
|
||||
results[i].Tags = getMessageTags(m.ID)
|
||||
if len(results) > 0 {
|
||||
ids := make([]string, len(results))
|
||||
for i, m := range results {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
tagMap := getTagsForIDs(ids)
|
||||
for i, m := range results {
|
||||
if tags, ok := tagMap[m.ID]; ok {
|
||||
results[i].Tags = tags
|
||||
} else {
|
||||
results[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
@@ -548,22 +559,109 @@ func LatestID(r *http.Request) (string, error) {
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
placeholder := `(?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
// Find which messages are actually unread (will change state)
|
||||
toUpdate := []string{}
|
||||
rows, err := db.Query(fmt.Sprintf(`SELECT ID FROM %s WHERE Read = 0 AND ID IN %s`, tenant("mailbox"), placeholder), args...) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
toUpdate = append(toUpdate, id)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
d := struct {
|
||||
if len(toUpdate) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updateArgs := make([]any, len(toUpdate))
|
||||
for i, id := range toUpdate {
|
||||
updateArgs[i] = id
|
||||
}
|
||||
updatePlaceholder := `(?` + strings.Repeat(",?", len(toUpdate)-1) + `)`
|
||||
|
||||
if _, err := db.Exec(fmt.Sprintf(`UPDATE %s SET Read = 1 WHERE ID IN %s`, tenant("mailbox"), updatePlaceholder), updateArgs...); err != nil { // #nosec
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range toUpdate {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
websockets.Broadcast("update", struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
}{ID: id, Read: true})
|
||||
}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
placeholder := `(?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
// Find which messages are actually read (will change state)
|
||||
toUpdate := []string{}
|
||||
rows, err := db.Query(fmt.Sprintf(`SELECT ID FROM %s WHERE Read = 1 AND ID IN %s`, tenant("mailbox"), placeholder), args...) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
toUpdate = append(toUpdate, id)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
if len(toUpdate) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updateArgs := make([]any, len(toUpdate))
|
||||
for i, id := range toUpdate {
|
||||
updateArgs[i] = id
|
||||
}
|
||||
updatePlaceholder := `(?` + strings.Repeat(",?", len(toUpdate)-1) + `)`
|
||||
|
||||
if _, err := db.Exec(fmt.Sprintf(`UPDATE %s SET Read = 0 WHERE ID IN %s`, tenant("mailbox"), updatePlaceholder), updateArgs...); err != nil { // #nosec
|
||||
return err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
for _, id := range toUpdate {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
websockets.Broadcast("update", struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false})
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
@@ -621,33 +719,6 @@ func MarkAllUnread() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessages deletes one or more messages in bulk
|
||||
func DeleteMessages(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -86,8 +86,19 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
}
|
||||
|
||||
// set tags for listed messages only
|
||||
for i, m := range results {
|
||||
results[i].Tags = getMessageTags(m.ID)
|
||||
if len(results) > 0 {
|
||||
ids := make([]string, len(results))
|
||||
for i, m := range results {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
tagMap := getTagsForIDs(ids)
|
||||
for i, m := range results {
|
||||
if tags, ok := tagMap[m.ID]; ok {
|
||||
results[i].Tags = tags
|
||||
} else {
|
||||
results[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -19,45 +18,62 @@ import (
|
||||
|
||||
var (
|
||||
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
|
||||
addTagMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// SetMessageTags will set the tags for a given database ID, removing any not in the array
|
||||
func SetMessageTags(id string, tags []string) ([]string, error) {
|
||||
// Clean and deduplicate incoming tags (case-insensitive)
|
||||
seen := make(map[string]struct{})
|
||||
applyTags := []string{}
|
||||
for _, t := range tags {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
|
||||
tagNames := []string{}
|
||||
currentTags := getMessageTags(id)
|
||||
origTagCount := len(currentTags)
|
||||
|
||||
for _, t := range applyTags {
|
||||
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
|
||||
if t == "" || !config.ValidTagRegexp.MatchString(t) {
|
||||
continue
|
||||
}
|
||||
lc := strings.ToLower(t)
|
||||
if _, exists := seen[lc]; exists {
|
||||
continue
|
||||
}
|
||||
seen[lc] = struct{}{}
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
|
||||
// Fetch existing tags once and index by lowercase name for O(1) lookup
|
||||
currentTags := getMessageTags(id)
|
||||
currentSet := make(map[string]struct{}, len(currentTags))
|
||||
for _, t := range currentTags {
|
||||
currentSet[strings.ToLower(t)] = struct{}{}
|
||||
}
|
||||
|
||||
// Build apply set for O(1) lookup when computing deletions
|
||||
applySet := make(map[string]struct{}, len(applyTags))
|
||||
for _, t := range applyTags {
|
||||
applySet[strings.ToLower(t)] = struct{}{}
|
||||
}
|
||||
|
||||
// Add tags not already on the message
|
||||
tagNames := []string{}
|
||||
for _, t := range applyTags {
|
||||
if _, exists := currentSet[strings.ToLower(t)]; exists {
|
||||
continue
|
||||
}
|
||||
name, err := addMessageTag(id, t)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
tagNames = append(tagNames, name)
|
||||
}
|
||||
|
||||
if origTagCount > 0 {
|
||||
currentTags = getMessageTags(id)
|
||||
|
||||
for _, t := range currentTags {
|
||||
if !tools.InArray(t, applyTags) {
|
||||
if err := deleteMessageTag(id, t); err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
}
|
||||
// Delete tags removed from the message in a single batch query
|
||||
toDelete := []string{}
|
||||
for _, t := range currentTags {
|
||||
if _, exists := applySet[strings.ToLower(t)]; !exists {
|
||||
toDelete = append(toDelete, t)
|
||||
}
|
||||
}
|
||||
if len(toDelete) > 0 {
|
||||
if err := deleteMessageTags(id, toDelete); err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,57 +89,63 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
|
||||
|
||||
// AddMessageTag adds a tag to a message
|
||||
func addMessageTag(id, name string) (string, error) {
|
||||
// prevent two identical tags being added at the same time
|
||||
addTagMutex.Lock()
|
||||
|
||||
var tagID int
|
||||
var foundName sql.NullString
|
||||
|
||||
q := sqlf.From(tenant("tags")).
|
||||
Select("ID").To(&tagID).
|
||||
Select("Name").To(&foundName).
|
||||
Where("Name = ?", name)
|
||||
|
||||
// if tag exists - add tag to message
|
||||
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
|
||||
addTagMutex.Unlock()
|
||||
// check message does not already have this tag
|
||||
var exists int
|
||||
|
||||
if err := sqlf.From(tenant("message_tags")).
|
||||
Select("COUNT(ID)").To(&exists).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
QueryRowAndClose(context.Background(), db); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists > 0 {
|
||||
// already exists
|
||||
return foundName.String, nil
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
_, err := sqlf.InsertInto(tenant("message_tags")).
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(context.TODO(), db)
|
||||
|
||||
return foundName.String, err
|
||||
}
|
||||
|
||||
// new tag, add to the database
|
||||
if _, err := sqlf.InsertInto(tenant("tags")).
|
||||
Set("Name", name).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
addTagMutex.Unlock()
|
||||
// Ensure the tag row exists; the UNIQUE index on Name makes concurrent inserts safe
|
||||
if _, err := db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (Name) VALUES (?)`, tenant("tags")), name); err != nil { // #nosec
|
||||
return name, err
|
||||
}
|
||||
|
||||
addTagMutex.Unlock()
|
||||
var tagID int
|
||||
var foundName string
|
||||
|
||||
// add tag to the message
|
||||
return addMessageTag(id, name)
|
||||
if err := sqlf.From(tenant("tags")).
|
||||
Select("ID").To(&tagID).
|
||||
Select("Name").To(&foundName).
|
||||
Where("Name = ?", name).
|
||||
QueryRowAndClose(context.TODO(), db); err != nil {
|
||||
return name, err
|
||||
}
|
||||
|
||||
// Check message does not already have this tag
|
||||
var exists int
|
||||
if err := sqlf.From(tenant("message_tags")).
|
||||
Select("COUNT(ID)").To(&exists).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
QueryRowAndClose(context.Background(), db); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists > 0 {
|
||||
return foundName, nil
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
_, err := sqlf.InsertInto(tenant("message_tags")).
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(context.TODO(), db)
|
||||
|
||||
return foundName, err
|
||||
}
|
||||
|
||||
// deleteMessageTags deletes multiple tags from a message in a single query
|
||||
func deleteMessageTags(id string, names []string) error {
|
||||
args := make([]any, 1+len(names))
|
||||
args[0] = id
|
||||
for i, n := range names {
|
||||
args[i+1] = n
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`DELETE FROM %s WHERE ID = ? AND TagID IN (SELECT ID FROM %s WHERE Name IN (?%s))`,
|
||||
tenant("message_tags"), tenant("tags"), strings.Repeat(",?", len(names)-1),
|
||||
) // #nosec
|
||||
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// DeleteMessageTag deletes a tag from a message
|
||||
@@ -340,6 +362,43 @@ func (d Metadata) tagsFromPlusAddresses() []string {
|
||||
return tools.SetTagCasing(tags)
|
||||
}
|
||||
|
||||
// getTagsForIDs fetches tags for a set of message IDs in a single query,
|
||||
// returning a map of message ID to tag names.
|
||||
func getTagsForIDs(ids []string) map[string][]string {
|
||||
result := make(map[string][]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT mt.ID, t.Name FROM %s t JOIN %s mt ON t.ID = mt.TagID WHERE mt.ID IN (?%s) ORDER BY mt.ID, t.Name`,
|
||||
tenant("Tags"), tenant("message_tags"), strings.Repeat(",?", len(ids)-1),
|
||||
) // #nosec
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
return result
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
return result
|
||||
}
|
||||
result[id] = append(result[id], name)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given database ID
|
||||
// Used when parsing a raw email.
|
||||
func getMessageTags(id string) []string {
|
||||
|
||||
@@ -57,7 +57,7 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
b.WriteString(env.GetHeader("Reply-To") + " ")
|
||||
b.WriteString(env.GetHeader("Return-Path") + " ")
|
||||
|
||||
h := html2text.Strip(env.HTML, true)
|
||||
h, _ := html2text.Strip(env.HTML, true)
|
||||
if h != "" {
|
||||
b.WriteString(h + " ")
|
||||
} else {
|
||||
|
||||
@@ -2,20 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
// IsFile returns whether a path exists and is a regular file.
|
||||
// Symlinks are deliberately rejected to prevent following links to
|
||||
// arbitrary files outside the intended location.
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func IsDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ import (
|
||||
"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")
|
||||
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
|
||||
@@ -12,13 +21,15 @@ import (
|
||||
// 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)
|
||||
func IsInternalIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast()
|
||||
ip.IsMulticast() ||
|
||||
cgnatRange.Contains(ip)
|
||||
}
|
||||
|
||||
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
|
||||
|
||||
@@ -20,13 +20,14 @@ func CreateSnippet(text, html string) string {
|
||||
}
|
||||
|
||||
if html != "" {
|
||||
data := html2text.Strip(html, false)
|
||||
data, err := html2text.Strip(html, false)
|
||||
if err == nil {
|
||||
if len(data) <= limit {
|
||||
return data
|
||||
}
|
||||
|
||||
if len(data) <= limit {
|
||||
return data
|
||||
return truncate(data, limit) + "..."
|
||||
}
|
||||
|
||||
return truncate(data, limit) + "..."
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsInternalIP(t *testing.T) {
|
||||
internal := []string{
|
||||
"127.0.0.1", // loopback
|
||||
"::1", // IPv6 loopback
|
||||
"10.0.0.1", // private
|
||||
"172.16.0.1", // private
|
||||
"192.168.1.1", // private
|
||||
"169.254.1.1", // link-local unicast
|
||||
"fe80::1", // IPv6 link-local
|
||||
"0.0.0.0", // unspecified
|
||||
"224.0.0.1", // multicast
|
||||
"100.64.0.1", // CGNAT start
|
||||
"100.127.255.255", // CGNAT end
|
||||
}
|
||||
external := []string{
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"100.128.0.1", // just outside CGNAT range
|
||||
}
|
||||
|
||||
for _, s := range internal {
|
||||
ip := net.ParseIP(s)
|
||||
if !IsInternalIP(ip) {
|
||||
t.Errorf("expected %s to be internal", s)
|
||||
}
|
||||
}
|
||||
for _, s := range external {
|
||||
ip := net.ParseIP(s)
|
||||
if IsInternalIP(ip) {
|
||||
t.Errorf("expected %s to be external", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidLinkURL(t *testing.T) {
|
||||
valid := []string{
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"https://example.com/path?q=1#anchor",
|
||||
}
|
||||
invalid := []string{
|
||||
"",
|
||||
"ftp://example.com",
|
||||
"example.com",
|
||||
"//example.com",
|
||||
"https://",
|
||||
}
|
||||
|
||||
for _, s := range valid {
|
||||
if !IsValidLinkURL(s) {
|
||||
t.Errorf("expected %q to be a valid link URL", s)
|
||||
}
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if IsValidLinkURL(s) {
|
||||
t.Errorf("expected %q to be an invalid link URL", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgsParser(t *testing.T) {
|
||||
tests := map[string][]string{}
|
||||
tests["this is a test"] = []string{"this", "is", "a", "test"}
|
||||
|
||||
963
package-lock.json
generated
963
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,12 @@ import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"net"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/mneis/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -119,30 +121,49 @@ func Run() {
|
||||
socketAddr, isSocket := socketAddress(SMTPAddr)
|
||||
|
||||
// handles `sendmail -bs`
|
||||
// telnet directly to SMTP
|
||||
// relay stdin/stdout to SMTP connection
|
||||
if UseB && UseS {
|
||||
var caller = telnet.StandardCaller
|
||||
switch isSocket {
|
||||
case true:
|
||||
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
network := "tcp"
|
||||
addr := SMTPAddr
|
||||
if isSocket {
|
||||
network = "unix"
|
||||
addr = socketAddr
|
||||
}
|
||||
|
||||
conn, err := net.Dial(network, addr)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(os.Stdout, conn)
|
||||
close(done)
|
||||
}()
|
||||
_, _ = io.Copy(conn, os.Stdin)
|
||||
if cw, ok := conn.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
const maxMessageSize = 1000 * 1024 * 1024
|
||||
body, err := io.ReadAll(io.LimitReader(os.Stdin, maxMessageSize+1))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading stdin")
|
||||
os.Exit(11)
|
||||
}
|
||||
if len(body) > maxMessageSize {
|
||||
fmt.Fprintf(os.Stderr, "message exceeds %d MiB size cap\n", maxMessageSize/(1024*1024))
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.
|
||||
@@ -29,6 +27,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
return fmt.Errorf("no To addresses specified")
|
||||
}
|
||||
|
||||
if err := validateLine(fromAddress.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range to {
|
||||
if err := validateLine(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !isSocket {
|
||||
return sendMail(addr, nil, fromAddress.Address, to, msg)
|
||||
}
|
||||
@@ -40,16 +47,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
|
||||
client, err := smtp.NewClient(conn, "")
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Set the sender
|
||||
if err := client.Mail(fromAddress.Address); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
return fmt.Errorf("error setting sender: %w", err)
|
||||
}
|
||||
|
||||
// Set the recipient
|
||||
for _, a := range to {
|
||||
if err := client.Rcpt(a); err != nil {
|
||||
return err
|
||||
@@ -61,29 +67,17 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = wc.Write(msg)
|
||||
if err != nil {
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
err = wc.Close()
|
||||
if err != nil {
|
||||
if err := wc.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
|
||||
if err := validateLine(from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, recipient := range to {
|
||||
if err := validateLine(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetMessage (method: GET) returns the Message as JSON
|
||||
@@ -32,9 +31,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -78,9 +75,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -133,10 +128,8 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
id := r.PathValue("id")
|
||||
partID := r.PathValue("partID")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -183,9 +176,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
if id == "latest" {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
@@ -35,8 +34,7 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -105,8 +103,7 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -158,8 +155,7 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
|
||||
@@ -10,11 +10,10 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/shortuuid"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
@@ -45,9 +44,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -42,11 +43,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := sendMessageParams{}
|
||||
|
||||
if err := decoder.Decode(&data.Body); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
httpJSONError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetAllTags (method: GET) will get all tags currently in use
|
||||
@@ -97,9 +96,7 @@ func RenameTag(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
tag := vars["tag"]
|
||||
tag := r.PathValue("tag")
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -141,9 +138,7 @@ func DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
tag := vars["tag"]
|
||||
tag := r.PathValue("tag")
|
||||
|
||||
if err := storage.DeleteTag(tag); err != nil {
|
||||
httpError(w, err.Error())
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
@@ -38,9 +37,8 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
|
||||
id := strings.TrimSuffix(path, ".html")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -123,9 +121,8 @@ func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
|
||||
id := strings.TrimSuffix(path, ".txt")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -42,10 +41,8 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: BinaryResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
id := r.PathValue("id")
|
||||
partID := r.PathValue("partID")
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
|
||||
@@ -213,14 +213,14 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// store asset address against message ID
|
||||
assetsMutex.Lock()
|
||||
if result, ok := assets[id]; ok {
|
||||
if !tools.InArray(address, result.Assets) {
|
||||
assetsMutex.Lock()
|
||||
result.Assets = append(result.Assets, address)
|
||||
assets[id] = result
|
||||
assetsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
assetsMutex.Unlock()
|
||||
|
||||
// encode with base64 to handle any special characters and group message ID with URL
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(id + ":" + address))
|
||||
|
||||
120
server/server.go
120
server/server.go
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/shortuuid"
|
||||
"github.com/axllent/mailpit/internal/snakeoil"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
@@ -28,8 +29,6 @@ import (
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/server/handlers"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -64,37 +63,43 @@ func Listen() {
|
||||
r := apiRoutes()
|
||||
|
||||
// kubernetes probes
|
||||
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
|
||||
r.HandleFunc("GET "+config.Webroot+"livez", handlers.HealthzHandler)
|
||||
r.HandleFunc("GET "+config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
|
||||
|
||||
// proxy handler for screenshots
|
||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler))
|
||||
|
||||
// virtual filesystem for /dist/ & some individual files
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"dist/", middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"api/", middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"favicon.ico", middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"favicon.svg", middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"mailpit.svg", middleWareFunc(embedController))
|
||||
r.Handle("GET "+config.Webroot+"notification.png", middleWareFunc(embedController))
|
||||
|
||||
// redirect to webroot if no trailing slash
|
||||
if config.Webroot != "/" {
|
||||
redirect := strings.TrimRight(config.Webroot, "/")
|
||||
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
||||
r.HandleFunc("GET "+redirect, middleWareFunc(addSlashToWebroot))
|
||||
}
|
||||
|
||||
// UI shortcut
|
||||
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage))
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET")
|
||||
// frontend testing + web UI via virtual index.html
|
||||
// Go's ServeMux wildcards must span a full path segment so {id}.html is invalid;
|
||||
// viewHandler dispatches on the path suffix instead.
|
||||
r.HandleFunc("GET "+config.Webroot+"view/", middleWareFunc(viewHandler))
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Handle("GET "+config.Webroot+"search", middleWareFunc(index))
|
||||
// Exact-match the webroot; stdlib "/" is always a subtree so we guard inside.
|
||||
r.HandleFunc("GET "+config.Webroot, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != config.Webroot {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
middleWareFunc(index)(w, r)
|
||||
})
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
logger.Log().Info("[http] enabling basic authentication")
|
||||
@@ -165,51 +170,51 @@ func Listen() {
|
||||
}
|
||||
}
|
||||
|
||||
func apiRoutes() *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
func apiRoutes() *http.ServeMux {
|
||||
r := http.NewServeMux()
|
||||
|
||||
// API V1
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages))
|
||||
r.HandleFunc("PUT "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus))
|
||||
r.HandleFunc("DELETE "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search))
|
||||
r.HandleFunc("DELETE "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch))
|
||||
r.HandleFunc("POST "+config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags))
|
||||
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags))
|
||||
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag))
|
||||
r.HandleFunc("DELETE "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw))
|
||||
r.HandleFunc("POST "+config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck))
|
||||
if config.EnableSpamAssassin != "" {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck))
|
||||
}
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig))
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath))
|
||||
|
||||
// Chaos
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
|
||||
r.HandleFunc("GET "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos))
|
||||
r.HandleFunc("PUT "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos))
|
||||
|
||||
// Prometheus metrics (if enabled and using existing server)
|
||||
if prometheus.GetMode() == "integrated" {
|
||||
r.HandleFunc(config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("GET "+config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
prometheus.GetHandler().ServeHTTP(w, r)
|
||||
})).Methods("GET")
|
||||
}))
|
||||
}
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", middleWareFunc(apiWebsocket)).Methods("GET")
|
||||
r.HandleFunc("GET "+config.Webroot+"api/events", middleWareFunc(apiWebsocket))
|
||||
|
||||
// return blank 200 response for OPTIONS requests for CORS
|
||||
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
||||
r.Handle("OPTIONS "+config.Webroot+"api/v1/", middleWareFunc(apiv1.GetOptions))
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -345,6 +350,21 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
}
|
||||
|
||||
// viewHandler routes /view/ requests based on path suffix.
|
||||
// Go's ServeMux requires wildcards to span a full path segment,
|
||||
// so patterns like /view/{id}.html are invalid; we dispatch manually here.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".html"):
|
||||
apiv1.GetMessageHTML(w, r)
|
||||
case strings.HasSuffix(path, ".txt"):
|
||||
apiv1.GetMessageText(w, r)
|
||||
default:
|
||||
index(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes.
|
||||
// Authentication and CORS are handled by middleWareFunc before this is reached.
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -328,6 +328,53 @@ func TestAPIv1Send(t *testing.T) {
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
|
||||
func TestAPIv1SendMaxMessageSize(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
original := config.MaxMessageSize
|
||||
defer func() { config.MaxMessageSize = original }()
|
||||
|
||||
config.MaxMessageSize = 1 // 1 MiB cap for the test
|
||||
|
||||
bigText := strings.Repeat("X", 2*1024*1024)
|
||||
oversized := fmt.Sprintf(`{
|
||||
"From": {"Email": "a@example.com"},
|
||||
"To": [{"Email": "b@example.com"}],
|
||||
"Subject": "oversize",
|
||||
"Text": %q
|
||||
}`, bigText)
|
||||
|
||||
t.Log("Sending oversize message via HTTP API (expect 413)")
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/send", strings.NewReader(oversized))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %s", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
assertEqual(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 413 for oversize body")
|
||||
|
||||
t.Log("Sending normal-sized message via HTTP API (expect 200)")
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
if _, err := clientPost(ts.URL+"/api/v1/send", string(jsonData)); err != nil {
|
||||
t.Errorf("expected success for in-bound payload, got: %s", err)
|
||||
}
|
||||
|
||||
t.Log("Setting MaxMessageSize=0 (unlimited), oversize should now succeed")
|
||||
config.MaxMessageSize = 0
|
||||
if _, err := clientPost(ts.URL+"/api/v1/send", oversized); err != nil {
|
||||
t.Errorf("expected success when MaxMessageSize=0, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAPIAuthMiddleware(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
@@ -75,16 +75,34 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
cursor: wait;
|
||||
|
||||
.loader-bar {
|
||||
width: 35%;
|
||||
height: 2px;
|
||||
background: $success;
|
||||
border-radius: 0 999px 999px 0;
|
||||
animation: loader-slide 1.2s ease-in-out 200ms infinite backwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(110vw);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(110vw);
|
||||
}
|
||||
}
|
||||
|
||||
// dark mode adjustments
|
||||
@include color-mode(dark) {
|
||||
.loader {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.property {
|
||||
color: #ee6969;
|
||||
|
||||
@@ -10,11 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading > 0" class="loader">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-muted" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading > 0" class="loader" role="status" aria-live="polite" aria-label="Loading">
|
||||
<div class="loader-bar"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,8 +132,13 @@ export default {
|
||||
mailbox.appInfo.LatestVersion
|
||||
"
|
||||
>
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||
available.
|
||||
<template v-if="isEdgeBuild || mailbox.appInfo.Version == 'dev'">
|
||||
Latest stable Mailpit ({{ mailbox.appInfo.LatestVersion }}) release
|
||||
</template>
|
||||
<template v-else>
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||
available
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default {
|
||||
|
||||
resizeIframe(el) {
|
||||
const i = el.target;
|
||||
if (typeof i.contentWindow.document.body.scrollHeight === "number") {
|
||||
if (i.contentWindow?.document?.body) {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||
}
|
||||
},
|
||||
@@ -226,10 +226,8 @@ export default {
|
||||
return;
|
||||
}
|
||||
const h = document.getElementById("preview-html");
|
||||
if (h) {
|
||||
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||
}
|
||||
if (h?.contentWindow?.document?.body) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -134,7 +134,6 @@ export default {
|
||||
|
||||
methods: {
|
||||
loadMessage() {
|
||||
this.message = false;
|
||||
const uri = this.resolve("/api/v1/message/" + this.$route.params.id);
|
||||
this.get(
|
||||
uri,
|
||||
@@ -605,8 +604,13 @@ export default {
|
||||
>
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
||||
<RouterLink
|
||||
:to="'/view/' + nextID"
|
||||
class="btn btn-outline-light"
|
||||
:class="nextID ? '' : 'disabled'"
|
||||
title="View next message"
|
||||
>
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,6 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
newline = []byte{'\n'}
|
||||
|
||||
// MessageHub global
|
||||
MessageHub *Hub
|
||||
)
|
||||
@@ -56,6 +54,7 @@ type Client struct {
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
@@ -79,6 +78,7 @@ func (c *Client) writePump() {
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
@@ -90,25 +90,14 @@ func (c *Client) writePump() {
|
||||
return
|
||||
}
|
||||
|
||||
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(message)
|
||||
|
||||
// Add queued chat messages to the current websocket message.
|
||||
n := len(c.send)
|
||||
for range n {
|
||||
_, _ = w.Write(newline)
|
||||
_, _ = w.Write(<-c.send)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
_ = c.conn.WriteMessage(websocket.PingMessage, []byte{})
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package websockets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -22,6 +22,9 @@ type Hub struct {
|
||||
|
||||
// Unregister requests from clients.
|
||||
unregister chan *Client
|
||||
|
||||
// clientCount is an atomic count of connected clients, safe for concurrent reads.
|
||||
clientCount atomic.Int64
|
||||
}
|
||||
|
||||
// WebsocketNotification struct for responses
|
||||
@@ -48,12 +51,14 @@ func (h *Hub) Run() {
|
||||
if _, ok := h.Clients[client]; !ok {
|
||||
logger.Log().Debugf("[websocket] client %s connected", client.conn.RemoteAddr().String())
|
||||
h.Clients[client] = true
|
||||
h.clientCount.Add(1)
|
||||
}
|
||||
case client := <-h.unregister:
|
||||
if _, ok := h.Clients[client]; ok {
|
||||
logger.Log().Debugf("[websocket] client %s disconnected", client.conn.RemoteAddr().String())
|
||||
delete(h.Clients, client)
|
||||
close(client.send)
|
||||
h.clientCount.Add(-1)
|
||||
}
|
||||
case message := <-h.Broadcast:
|
||||
for client := range h.Clients {
|
||||
@@ -62,6 +67,7 @@ func (h *Hub) Run() {
|
||||
default:
|
||||
close(client.send)
|
||||
delete(h.Clients, client)
|
||||
h.clientCount.Add(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +76,7 @@ func (h *Hub) Run() {
|
||||
|
||||
// Broadcast will spawn a broadcast message to all connected clients
|
||||
func Broadcast(t string, msg any) {
|
||||
if MessageHub == nil || len(MessageHub.Clients) == 0 {
|
||||
if MessageHub == nil || MessageHub.clientCount.Load() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,10 +90,6 @@ func Broadcast(t string, msg any) {
|
||||
return
|
||||
}
|
||||
|
||||
// add a very small delay to prevent broadcasts from being interpreted
|
||||
// as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
go func() { MessageHub.Broadcast <- b }()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user