Compare commits

..

44 Commits

Author SHA1 Message Date
Ralph Slooten
af8756a32c Merge branch 'release/v1.30.0' 2026-05-14 16:59:29 +12:00
Ralph Slooten
a9058f40db Release v1.30.0 2026-05-14 16:59:29 +12:00
Ralph Slooten
3b65ee936a Chore: Update caniemail test database 2026-05-14 16:40:11 +12:00
Ralph Slooten
bb81b62357 Chore: Update node dependencies 2026-05-14 16:38:54 +12:00
Ralph Slooten
e27d30bda7 Chore: Update Go dependencies 2026-05-14 16:37:56 +12:00
Ralph Slooten
cae0f638af Enhance sendmail functionality with message size limit and input validation 2026-05-14 16:36:27 +12:00
Ralph Slooten
786f263d32 Chore: Add message ingest max-message-size flag and refactor message handling 2026-05-14 16:24:58 +12:00
Ralph Slooten
8041eac509 Cleanup 2026-05-14 16:23:29 +12:00
Ralph Slooten
b7e4146dbf Chore: Add message dump max-message-size flag and refactor message handling 2026-05-14 16:23:21 +12:00
Ralph Slooten
5ec074208c Use httpClient for HTTP requests in loadIDs and saveMessages functions 2026-05-14 15:13:52 +12:00
Ralph Slooten
b82960928a Fix typo 2026-05-14 15:13:43 +12:00
Ralph Slooten
4ab532b9aa Security: Fix concurrent map read & write in proxy CSS rewriter (GHSA-w4vj-r5pg-3722) 2026-05-14 15:02:07 +12:00
Ralph Slooten
35079d182c Security: Fix for path traversal & arbitrary file write in mailpit dump --http via attacker-controlled message IDs (GHSA-qx5x-85p8-vg4j)
This fix also adds HTTP data limits to prevent excessively large files being transmitted by an attacker-controlled server (fake Mailpit).
2026-05-14 15:02:07 +12:00
Ralph Slooten
04c779994b Security: Block internal IP access by default in HTML check (GHSA-j3fj-qppj-fmmc)
This addresses an incomplete fix for GHSA-6jxm-fv7w-rw5j which did not restrict access to internal IP addresses.
2026-05-14 15:02:07 +12:00
Ralph Slooten
bcd1bc71ee Security: Include CGNAT (Carrier-Grade NAT) in internal IP checks (GHSA-j3fj-qppj-fmmc)
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.

This means traffic from multiple customers exits through a small pool of public IPs - a second layer of NAT on top of whatever NAT the customer's own router does (hence "double NAT").
2026-05-14 15:01:36 +12:00
Ralph Slooten
136bdde953 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)
This is a configurable limit (in MB's) which can optionally be disabled by setting it to 0.
2026-05-12 17:22:00 +12:00
Ralph Slooten
499a543963 Feature: New loading indicator, reduce flash during message transitions (#682) 2026-05-12 15:27:12 +12:00
Ralph Slooten
8b4c9d1267 Update AppAbout.vue: Enhance version notification display for stable and development builds 2026-05-10 10:41:38 +12:00
Ralph Slooten
1cabac31ad Update README.md: Adjust email processing rates and clarify email pruning methods 2026-05-10 10:24:29 +12:00
Ralph Slooten
da7b82378c Build: Tag Docker edge build with next patch versions 2026-05-09 17:55:03 +12:00
Ralph Slooten
0702241fa5 Fix test expectations and handle Strip function return values in html2text tests 2026-05-09 17:26:15 +12:00
Ralph Slooten
8d72191704 Prevent duplicate extraction calls in the extract function 2026-05-09 17:22:51 +12:00
Ralph Slooten
052afdf929 Rename variable for clarity in extract function 2026-05-09 17:22:07 +12:00
Ralph Slooten
c1fbbffded Refactor html2text.Strip to return an error and handle it in storage and tools packages 2026-05-09 17:21:36 +12:00
Ralph Slooten
6e2c42d2bc Improve error handling in autoForwardMessage and ensure proper client closure in createForwardingSMTPClient 2026-05-09 17:16:52 +12:00
Ralph Slooten
da8eb3ece8 Fix: Validate SMTP XCLIENT args before processing 2026-05-09 17:13:22 +12:00
Ralph Slooten
4502cdc358 Handle error in writePump when sending ping messages 2026-05-09 17:06:14 +12:00
Ralph Slooten
fbb63c89dd Chore: Simplify writePump by using WriteMessage and remove unnecessary newline handling 2026-05-09 17:05:22 +12:00
Ralph Slooten
71bd44bbb5 Chore: Ensure websocket connection is closed on client unregistration 2026-05-09 17:02:48 +12:00
Ralph Slooten
b997fff7eb Chore: Refactor Hub to use atomic clientCount for safe concurrent client tracking 2026-05-09 17:01:47 +12:00
Ralph Slooten
034a480a39 Chore: Refactor addMessageTag function to remove mutex and ensure safe concurrent inserts 2026-05-09 16:48:05 +12:00
Ralph Slooten
f575b53854 Chore: Refactor pruneMessages function to eliminate duplicate ID checks using a map 2026-05-09 16:43:40 +12:00
Ralph Slooten
d469aac87c Chore: Optimize MarkRead and MarkUnread functions to reduce database calls and improve performance 2026-05-09 16:40:27 +12:00
Ralph Slooten
e4c3442e39 Chore: Enhance SetMessageTags function to improve tag handling and batch deletions 2026-05-09 16:35:21 +12:00
Ralph Slooten
f11fc1ffe0 Chore: Optimize tag retrieval by batching message IDs in List and Search functions 2026-05-09 16:27:58 +12:00
Ralph Slooten
40c5936f79 Chore: Refactor MarkRead and MarkUnread functions to only broadcast changes of modified messages 2026-05-09 16:13:05 +12:00
Ralph Slooten
8bc966e618 Chore: Refactor Prometheus metrics implementation and remove unused dependencies 2026-05-06 16:28:43 +12:00
Ralph Slooten
ec2a0851ab Build: Update CI actions to use npm ci 2026-05-06 15:41:01 +12:00
Ralph Slooten
4bdbeebcc0 Chore: Bump axios version to v1.16.0 2026-05-06 15:34:49 +12:00
Ralph Slooten
10430f7dce Chore: Improve iframe height adjustment with optional chaining 2026-05-05 17:41:17 +12:00
Ralph Slooten
878c68bb49 Chore: Replace lithammer/shortuuid with custom shortuuid implementation and update tests 2026-05-05 17:09:55 +12:00
Ralph Slooten
86b0cf8557 Chore: Remove go-telnet dependency and implement TCP/Unix socket handling for SMTP 2026-05-05 16:48:33 +12:00
Ralph Slooten
123ec9a354 Chore: Remove logrus dependency and implement slog-based logging 2026-05-05 16:48:33 +12:00
Ralph Slooten
3b2423bdf1 Chore: Remove gorilla/mux dependency and replace with stdlib routing 2026-05-05 16:47:51 +12:00
53 changed files with 1930 additions and 1106 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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