Compare commits

..

49 Commits

Author SHA1 Message Date
Ralph Slooten
3a222dd147 Merge branch 'release/v1.29.3' 2026-03-10 15:29:47 +13:00
Ralph Slooten
857cf78984 Release v1.29.3 2026-03-10 15:29:45 +13:00
Ralph Slooten
6802e24e55 Chore: Update node dependencies 2026-03-10 15:21:02 +13:00
Ralph Slooten
deaab34cdd Chore: Update Go dependencies 2026-03-10 15:18:59 +13:00
Ralph Slooten
ee9863289a Chore: Refactor timezone handling in searchQueryBuilder 2026-03-10 12:07:52 +13:00
Ralph Slooten
70037e96f4 Chore: Update Content-Disposition header to use inline display and escape filename 2026-03-10 12:03:35 +13:00
Ralph Slooten
fc0b016549 Chore: Improve transaction handling in pruneMessages and fix loop continuation in InitDB 2026-03-10 11:53:36 +13:00
Ralph Slooten
140633718c Chore: Limit subject length to 100 characters in browser notifications 2026-03-10 11:31:21 +13:00
Ralph Slooten
f40911c580 Security: Escape ContentID in HTML replacement to prevent regex injection 2026-03-10 11:27:47 +13:00
Ralph Slooten
3073ef9afe Chore: Replace localStorage retrieval with a dedicated function for default release addresses 2026-03-10 11:20:33 +13:00
Ralph Slooten
804d49b7ca Chore: Set margin & padding to HTML screenshot to prevent transparent top/left border 2026-03-10 11:09:28 +13:00
Ralph Slooten
7d29dff5e7 Security: Enhance HTML sanitization in screenshot generation 2026-03-10 10:24:40 +13:00
Ralph Slooten
bc8a737d4f Chore: Simplify HTML decoding function in screenshot generation using DOMParser 2026-03-10 10:04:47 +13:00
Ralph Slooten
b99be839a0 Security: Enhance HTML sanitization in message view 2026-03-10 10:02:10 +13:00
Ralph Slooten
c1db706677 Update inline TLS verification docs for healthcheck and link checks 2026-03-09 12:44:39 +13:00
Ralph Slooten
ab3fc5ead7 Chore: Use local hostname for EHLO/HELO in SMTP communication 2026-03-09 12:38:34 +13:00
Ralph Slooten
a72d42c8d4 Chore: Set timeout for HTTP client in webhook Send function 2026-03-09 12:34:50 +13:00
Ralph Slooten
f8052e1d56 Security: Limit proxy requests to 50MB to prevent OOM attacks 2026-03-09 12:31:17 +13:00
Ralph Slooten
267bf8b639 Security: Enhance CORS origin handling to respect host:port distinctions 2026-03-09 12:30:56 +13:00
Ralph Slooten
51e327f259 Fix: Update SQL query to use tenant when using is:tagged filter 2026-03-09 11:37:40 +13:00
Ralph Slooten
bb6bdf629d Chore: Refactor events websocket middleware 2026-03-09 11:20:45 +13:00
Ralph Slooten
a0a4ebb943 Chore: Refactor API send authentication logic 2026-03-09 11:08:19 +13:00
Ville Skyttä
ba00ea5a21 Chore: Switch to math/rand/v2
Insignificant as in tests only, but there's no particular reason not to.
2026-03-07 22:54:04 +13:00
Ville Skyttä
2afc52c6fe Chore: Refactor code with go fix
Done with `go fix ./...` using go 1.26.0.
2026-03-03 16:03:28 +13:00
dependabot[bot]
5e9c522402 Chore: Bump minimatch from 10.2.2 to 10.2.4
Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.2 to 10.2.4.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v10.2.2...v10.2.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 22:46:16 +13:00
Ralph Slooten
7bb330a07a Chore: Use last release + git hash in Docker edge versions 2026-03-02 22:38:38 +13:00
Ralph Slooten
ffb3067680 Merge tag 'v1.29.2' into develop
Release v1.29.2
2026-02-25 12:28:48 +13:00
Ralph Slooten
dc3e7e701f Merge branch 'release/v1.29.2' 2026-02-25 12:28:45 +13:00
Ralph Slooten
f1d0bcda90 Release v1.29.2 2026-02-25 12:28:44 +13:00
Ralph Slooten
4f651e4f14 Chore: Update caniemail test database 2026-02-25 12:10:33 +13:00
Ralph Slooten
c3819ca26d Chore: Update node dependencies 2026-02-25 12:09:34 +13:00
Ralph Slooten
4febeb1acd Chore: Update Go dependencies 2026-02-25 12:07:32 +13:00
Ralph Slooten
10ad4df8cc Security: Prevent Server-Side Request Forgery (SSRF) via Link Check API ([GHSA-mpf7-p9x7-96r3](https://github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3))
By default all internal HTTP requests are now blocked, unless mailpit is started with the `--allow-internal-http-requests` flag (env  `MP_ALLOW_INTERNAL_HTTP_REQUESTS=true`).
2026-02-24 14:22:02 +13:00
Ralph Slooten
632113fcc5 Fix: Include 8BITMIME in SMTPD EHLO response (#648) 2026-02-24 11:25:19 +13:00
Ralph Slooten
08ed46fc46 Use const instead of let 2026-02-21 22:43:51 +13:00
Ralph Slooten
6927c2b73b Chore: Upgrade eslint JavaScript linting 2026-02-21 22:43:34 +13:00
Matthew Spahr
ac81da5ae0 Fix: Update install instructions when setting INSTALL_PATH 2026-02-17 20:51:14 +13:00
Ralph Slooten
f1d55e4e39 Release v1.29.1 2026-02-13 20:57:09 +13:00
Ralph Slooten
b622252411 Merge tag 'v1.29.1' into develop
Release v1.29.1
2026-02-13 20:47:03 +13:00
Ralph Slooten
5527379475 Merge branch 'release/v1.29.1' 2026-02-13 20:46:59 +13:00
Ralph Slooten
1d87f1164e Chore: Update node dependencies 2026-02-13 20:44:34 +13:00
Ralph Slooten
b4ca68eb48 Chore: Update Go dependencies 2026-02-13 20:38:19 +13:00
dependabot[bot]
971ae95a67 Chore: Bump axios from 1.13.4 to 1.13.5
Bumps [axios](https://github.com/axios/axios) from 1.13.4 to 1.13.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.4...v1.13.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 18:27:21 +13:00
Ralph Slooten
c8caa29e24 Fix: Enable "Mark all read" button (Inbox) when new message is received 2026-02-09 15:38:11 +13:00
Ralph Slooten
7d314d2b50 Chore: Add CORS error logging and update error messages for failed CORS requests 2026-02-08 11:19:54 +13:00
Ralph Slooten
9d2f30787a Fix spelling 2026-02-08 11:17:17 +13:00
Ralph Slooten
b9d071db81 Update contributing document 2026-02-05 17:05:12 +13:00
Ralph Slooten
a5ee550ba3 Rebuild changelog 2026-02-01 16:15:27 +13:00
Ralph Slooten
3e41beb214 Merge tag 'v1.29.0' into develop
Release v1.29.0
2026-02-01 16:12:05 +13:00
53 changed files with 1330 additions and 1130 deletions

View File

@@ -9,6 +9,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # required for github-action-get-previous-tag
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -29,7 +31,12 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: benjlevesque/short-sha@v3.0
- name: Get previous git tag
uses: WyriHaximus/github-action-get-previous-tag@v2
id: previous-tag
- name: Get short SHA
uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Build and push
@@ -38,7 +45,7 @@ jobs:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
"VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge

View File

@@ -2,6 +2,66 @@
Notable changes to Mailpit will be documented in this file.
## [v1.29.3]
### Security
- Enhance CORS origin handling to respect host:port distinctions
- Limit proxy requests to 50MB to prevent OOM attacks
- Enhance HTML sanitization in message view
- Enhance HTML sanitization in screenshot generation
- Escape ContentID in HTML replacement to prevent regex injection
### Chore
- Use last release + git hash in Docker edge versions
- Bump minimatch from 10.2.2 to 10.2.4
- Refactor code with go fix
- Switch to math/rand/v2
- Refactor API send authentication logic
- Refactor events websocket middleware
- Set timeout for HTTP client in webhook Send function
- Use local hostname for EHLO/HELO in SMTP communication
- Simplify HTML decoding function in screenshot generation using DOMParser
- Set margin & padding to HTML screenshot to prevent transparent top/left border
- Replace localStorage retrieval with a dedicated function for default release addresses
- Limit subject length to 100 characters in browser notifications
- Improve transaction handling in pruneMessages and fix loop continuation in InitDB
- Update Content-Disposition header to use inline display and escape filename
- Refactor timezone handling in searchQueryBuilder
- Update Go dependencies
- Update node dependencies
### Fix
- Update SQL query to use tenant when using is:tagged filter
## [v1.29.2]
### Security
- Prevent Server-Side Request Forgery (SSRF) via Link Check API ([GHSA-mpf7-p9x7-96r3](https://github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3))
### Chore
- Upgrade eslint JavaScript linting
- Update Go dependencies
- Update node dependencies
- Update caniemail test database
### Fix
- Update install instructions when setting INSTALL_PATH
- Include 8BITMIME in SMTPD EHLO response ([#648](https://github.com/axllent/mailpit/issues/648))
## [v1.29.1]
### Chore
- Add CORS error logging and update error messages for failed CORS requests
- Bump axios from 1.13.4 to 1.13.5
- Update Go dependencies
- Update node dependencies
### Fix
- Enable "Mark all read" button (Inbox) when new message is received
## [v1.29.0]
### Feature
@@ -9,23 +69,28 @@ Notable changes to Mailpit will be documented in this file.
- Option to display/hide attachment information in message view in web UI including checksums, content type & disposition
### Chore
- Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620))
- Update Go dependencies
- Update node dependencies
- Add support for multi-origin CORS settings and apply to events websocket ([#630](https://github.com/axllent/mailpit/issues/630))
- Add support for webhook delay ([#627](https://github.com/axllent/mailpit/issues/627))
- Update Go dependencies
- Update node dependencies
### Test
- Add CORS tests
- Add message summary attachment checksum tests
## [v1.28.4]
### Chore
- Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620))
- Update Go dependencies
- Update node dependencies
### Fix
- Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 ([#621](https://github.com/axllent/mailpit/issues/621))
- Prevent nested MAIL command during an active SMTP transaction ([#623](https://github.com/axllent/mailpit/issues/623))
- Avoid error on image type assertion in thumbnail generation
### Test
- Add CORS tests
- Add message summary attachment checksum tests
## [v1.28.3]

View File

@@ -1,16 +1,10 @@
# Contributing guide
Thank you for your interest in contributing to Mailpit, your help is greatly appreciated! Please follow the guidelines below to ensure a smooth contribution process.
## Code of conduct
Please be respectful and considerate in all interactions. Mailpit is open source and free of charge, however is the result of thousands of hours of work.
# Contributing to Mailpit
Thank you for your interest in contributing to Mailpit!
## Reporting issues and feature requests
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Pleas do not report security issues here (see below).
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Please **do not** report security issues here (see below).
## Reporting security issues
@@ -18,44 +12,11 @@ If you find a bug or have a feature request, please [open an issue](https://gith
Please do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.
## Contributing code
## How to contribute (pull request)
Please ensure your code is clean and well-commented, and [passes linting](https://mailpit.axllent.org/docs/development/code-linting/) before submitting a Pull Request. Contributions should enhance the functionality or usability of Mailpit, focusing on quality over quantity.
1. **Fork the repository**
Click the "Fork" button at the top right of this repository to create your own copy.
Note that while assistance from AI tools is perfectly acceptable, **"[vibe coded](https://en.wikipedia.org/wiki/Vibe_coding)" pull requests will most likely not be accepted.**
We value the unique insights and creativity that individual contributors bring to the project.
2. **Clone your fork**
```bash
git clone https://github.com/your-username/mailpit.git
cd mailpit
```
3. **Create a branch**
Use a descriptive branch name:
```bash
git checkout -b feature/your-feature-name
```
4. **Make your changes**
Write clear, concise code and include comments where necessary.
5. **Test your changes**
Run all tests to ensure nothing is broken. This is a mandatory step as pull requests cannot be merged unless they pass the automated testing.
6. **Ensure your changes pass linting**
Ensure your changes pass the [code linting](https://mailpit.axllent.org/docs/development/code-linting/) requirements. This is a mandatory step as pull requests cannot be merged unless they pass the automated linting tests.
7. **Commit and push**
Write a clear commit message:
```bash
git add .
git commit -m "Describe your changes"
git push origin feature/your-feature-name
```
8. **Open a pull request**
Go to your fork on GitHub and open a pull request against the `develop` branch. Fill out the PR template and describe your changes.
---
Thank you for helping make this project awesome!
Thank you for your understanding and for contributing to Mailpit!

View File

@@ -79,7 +79,7 @@ sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/i
You can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:
```shell
INSTALL_PATH=/usr/bin sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
sudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```

View File

@@ -41,7 +41,8 @@ settings to determine the HTTP bind interface & port.
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// do not verify TLS in case this instance is using HTTPS
// do not verify TLS if this instance is using HTTPS as we connect using IP
// so won't be the same as the cert
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}
client := &http.Client{Transport: conf}

View File

@@ -105,6 +105,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().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)")
@@ -250,6 +251,9 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") {
config.AllowInternalHTTPRequests = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}

View File

@@ -127,6 +127,10 @@ var (
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
BlockRemoteCSSAndFonts = false
// AllowInternalHTTPRequests will allow HTTP requests to internal IP addresses (e.g., loopback, private, link-local, or multicast) when set to true.
// This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons.
AllowInternalHTTPRequests = false
// CLITagsArg is used to map the CLI args
CLITagsArg string

View File

@@ -94,9 +94,9 @@ func parseTagsDisable(s string) error {
return nil
}
parts := strings.Split(strings.ToLower(s), ",")
parts := strings.SplitSeq(strings.ToLower(s), ",")
for _, p := range parts {
for p := range parts {
switch strings.TrimSpace(p) {
case "x-tags", "xtags":
TagsDisableXTags = true

View File

@@ -26,8 +26,8 @@ func parseMaxAge() error {
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
}
if strings.HasSuffix(MaxAge, "h") {
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
if before, ok := strings.CutSuffix(MaxAge, "h"); ok {
hours, err := strconv.Atoi(before)
if err != nil {
return err
}
@@ -221,8 +221,8 @@ func validateForwardConfig() error {
}
to := []string{}
addresses := strings.Split(SMTPForwardConfig.To, ",")
for _, a := range addresses {
addresses := strings.SplitSeq(SMTPForwardConfig.To, ",")
for a := range addresses {
a = strings.TrimSpace(a)
m, err := mail.ParseAddress(a)
if err != nil {
@@ -263,8 +263,8 @@ func parseChaosTriggers() error {
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
parts := strings.Split(ChaosTriggers, ",")
for _, p := range parts {
parts := strings.SplitSeq(ChaosTriggers, ",")
for p := range parts {
p = strings.TrimSpace(p)
if !re.MatchString(p) {
return fmt.Errorf("invalid argument: %s", p)

48
go.mod
View File

@@ -1,19 +1,19 @@
module github.com/axllent/mailpit
go 1.24.3
go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/ghru/v2 v2.0.2
github.com/axllent/ghru/v2 v2.1.0
github.com/axllent/semver v1.0.0
github.com/goccy/go-yaml v1.19.2
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime/v2 v2.2.0
github.com/klauspost/compress v1.18.3
github.com/kovidgoyal/imaging v1.8.19
github.com/jhillyerd/enmime/v2 v2.3.0
github.com/klauspost/compress v1.18.4
github.com/kovidgoyal/imaging v1.8.20
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
@@ -24,12 +24,12 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/tg123/go-htpasswd v1.2.4
github.com/vanng822/go-premailer v1.30.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
golang.org/x/time v0.14.0
modernc.org/sqlite v1.44.3
github.com/vanng822/go-premailer v1.32.0
golang.org/x/crypto v0.48.0
golang.org/x/net v0.51.0
golang.org/x/text v0.34.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.46.1
)
require (
@@ -38,11 +38,11 @@ require (
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.8.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
@@ -50,32 +50,30 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.4 // indirect
github.com/olekukonko/ll v0.1.7 // indirect
github.com/olekukonko/tablewriter v1.1.3 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sys v0.40.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

115
go.sum
View File

@@ -6,8 +6,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/axllent/ghru/v2 v2.0.2 h1:xalJupjJAU8Kcs39AwpG53qbcbi3+WKM98BEoQWf/zU=
github.com/axllent/ghru/v2 v2.0.2/go.mod h1:seMMjx8/0r5ZAL7c0vwTPIRoyN0AoTUqAylZEWZWGK4=
github.com/axllent/ghru/v2 v2.1.0 h1:zNW96KO+rmXggizZhHzIX7MExOiV4jx+63Y9nXlwLV0=
github.com/axllent/ghru/v2 v2.1.0/go.mod h1:8l7s1phdc375vvf8LHxT7wnJqXlThdHJR5EBtHNWhTg=
github.com/axllent/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=
@@ -16,14 +16,11 @@ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/
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=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,12 +30,14 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-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-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/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=
@@ -58,20 +57,16 @@ github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jhillyerd/enmime/v2 v2.2.0 h1:Pe35MB96eZK5Q0XjlvPftOgWypQpd1gcbfJKAt7rsB8=
github.com/jhillyerd/enmime/v2 v2.2.0/go.mod h1:SOBXlCemjhiV2DvHhAKnJiWrtJGS/Ffuw4Iy7NjBTaI=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=
github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/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.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
@@ -83,8 +78,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
@@ -97,8 +92,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE=
github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -111,15 +106,13 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -150,13 +143,13 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.30.0 h1:9oAp2PrJm4rvPnBgP57J/K1sJ1fQvSrU8TxamFvvwGU=
github.com/vanng822/go-premailer v1.30.0/go.mod h1:1okMIRBIcWIK1g5vJKaXi2ytD1ulsIc9wUGwK7UD3/I=
github.com/vanng822/go-premailer v1.32.0 h1:dW1y2IKSBQyYIwMc9comDA2e+00/pJ1kVXf3v4sqAJo=
github.com/vanng822/go-premailer v1.32.0/go.mod h1:4gVC6Hs+ESjSSfB1ohMwLqwuGoJ76cc0c2VM7DYqr0s=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -164,19 +157,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -186,8 +177,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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=
@@ -209,8 +200,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -229,41 +220,39 @@ 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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -272,8 +261,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2025-11-10 14:54:35 +0000",
"last_update_date":"2026-02-16 15:39:06 +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":[
{
@@ -270,7 +270,7 @@
"last_test_date":"2024-01-17",
"test_url":"https://www.caniemail.com/tests/css-backdrop-filter.html",
"test_results_url":"https://testi.at/proj/p4r7t9n30o7nh7vvfpn",
"stats":{"apple-mail":{"macos":{"10":"y #1","11":"u","12":"u","13":"y #1"},"ios":{"11":"n","12":"n","13":"y #1","14":"y #1","15":"y #1"}},"gmail":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"n"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"u"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}},
"stats":{"apple-mail":{"macos":{"10":"y #1","11":"u","12":"u","13":"y #1"},"ios":{"11":"n","12":"n","13":"y #1","14":"y #1","15":"y #1"}},"gmail":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"n"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"u"}},"protonmail":{"desktop-webmail":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}},
"notes":null,
"notes_by_num":{"1":"Works with prefix `-webkit`"}
},
@@ -665,7 +665,7 @@
"description":"Changes the default colors of HTML elements. Useful for when you want an email to display only in a dark color scheme or only a light scheme, regardless of user settings",
"url":"https://www.caniemail.com/features/css-color-scheme/",
"category":"css",
"tags":[],
"tags":["accessibility"],
"keywords":"dark mode, light mode",
"last_test_date":"2023-09-18",
"test_url":"https://www.caniemail.com/tests/css-color-scheme.html",
@@ -1006,9 +1006,9 @@
"last_test_date":"2021-05-07",
"test_url":"https://www.caniemail.com/tests/css-clamp.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list",
"stats":{"apple-mail":{"macos":{"13":"n","14.0":"y"},"ios":{"13":"n","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"n","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":{"13":"n","14.0":"y"},"ios":{"13":"n","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"n","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n","6.2.06.0":"a #2"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":"Partial. Not supported with non Google accounts."}
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Not supported with Microsoft accounts."}
},
{
@@ -1017,7 +1017,7 @@
"description":"Enables setting two colors (one for light and the other for dark mode) for a property.",
"url":"https://www.caniemail.com/features/css-function-light-dark/",
"category":"css",
"tags":[],
"tags":["accessibility"],
"keywords":"dark, light",
"last_test_date":"2024-08-14",
"test_url":"https://www.caniemail.com/tests/css-function-light-dark.html",
@@ -1038,9 +1038,9 @@
"last_test_date":"2021-05-07",
"test_url":"https://www.caniemail.com/tests/css-clamp.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list",
"stats":{"apple-mail":{"macos":{"13":"y","14.0":"y"},"ios":{"13":"y","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":{"13":"y","14.0":"y"},"ios":{"13":"y","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n","6.2.06.0":"a #2"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":"Partial. Not supported with non Google accounts."}
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Not supported with Microsoft accounts."}
},
{
@@ -1054,9 +1054,9 @@
"last_test_date":"2021-05-07",
"test_url":"https://www.caniemail.com/tests/css-clamp.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list",
"stats":{"apple-mail":{"macos":{"13":"y","14.0":"y"},"ios":{"13":"y","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":{"13":"y","14.0":"y"},"ios":{"13":"y","14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"a #1"},"android":{"2021-05":"a #1"},"mobile-webmail":{"2021-05":"y"}},"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":"n","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n","2023-12":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.0":"n","6.2.06.0":"a #2"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"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":"Partial. Not supported with non Google accounts."}
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Not supported with Microsoft accounts."}
},
{
@@ -1118,7 +1118,7 @@
"last_test_date":"2024-06-19",
"test_url":"https://www.caniemail.com/tests/css-hyphenate-character.html",
"test_results_url":"https://testi.at/proj/vr3e1e5bikda08oxc2",
"stats":{"apple-mail":{"macos":{"20":"n","21":"n","22":"n","23":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"},"mobile-webmail":{"2024-06":"n"}},"orange":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-06":"n"},"macos":{"2024-06":"n"},"outlook-com":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"yahoo":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"aol":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"samsung-email":{"android":{"2024-06":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"hey":{"desktop-webmail":{"2024-06":"u"}},"mail-ru":{"desktop-webmail":{"2024-06":"a #1"}},"fastmail":{"desktop-webmail":{"2024-06":"u"}},"laposte":{"desktop-webmail":{"2024-06":"u"}},"gmx":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"web-de":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-06":"u"},"android":{"2024-06":"u"}}},
"stats":{"apple-mail":{"macos":{"20":"n","21":"n","22":"n","23":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"},"mobile-webmail":{"2024-06":"n"}},"orange":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-06":"n"},"macos":{"2024-06":"n"},"outlook-com":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"yahoo":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"aol":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"samsung-email":{"android":{"2024-06":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-06":"y"},"ios":{"2024-06":"y"},"android":{"2024-06":"y"}},"hey":{"desktop-webmail":{"2024-06":"u"}},"mail-ru":{"desktop-webmail":{"2024-06":"a #1"}},"fastmail":{"desktop-webmail":{"2024-06":"u"}},"laposte":{"desktop-webmail":{"2024-06":"u"}},"gmx":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"web-de":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-06":"u"},"android":{"2024-06":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Does not support encoded character values"}
},
@@ -1177,7 +1177,7 @@
"description":"",
"url":"https://www.caniemail.com/features/css-inline-size/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":null,
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
@@ -1353,7 +1353,7 @@
"description":null,
"url":"https://www.caniemail.com/features/css-list-style/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":null,
"last_test_date":"2020-04-20",
"test_url":"https://www.caniemail.com/tests/css-list.html",
@@ -1614,9 +1614,9 @@
"last_test_date":"2023-08-31",
"test_url":"https://www.caniemail.com/tests/css-nesting.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list",
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n","137.0b3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n","137.0b3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"y"},"ios":{"2023-08":"a #5"},"android":{"2023-08":"a #5"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. `E { F {}}` doesnt work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."}
"notes_by_num":{"1":"Partial. `E { F {}}` doesnt work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts.","5":"Partial. `@media` is not fully supported, and `& & &` syntax not supported"}
},
{
@@ -1678,7 +1678,7 @@
"last_test_date":"2024-06-13",
"test_url":"https://www.caniemail.com/tests/css-widows.html",
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"y #4"},"ios":{"2024-05":"y #4"},"android":{"2024-05":"y #4"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `orphans` to work","2":"Buggy. `orphans` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `orphans` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
},
@@ -1859,6 +1859,22 @@
"notes_by_num":{"1":"Partial. Only supported on type selectors.","2":"Not supported. `<input>` elements are transformed into `<noinput>`."}
},
{
"slug":"css-pseudo-class-default",
"title":":default",
"description":"Selects form elements that are the default in a group of related elements.",
"url":"https://www.caniemail.com/features/css-pseudo-class-default/",
"category":"css",
"tags":["accessibility"],
"keywords":"pseudo-class, form",
"last_test_date":"2026-02-05",
"test_url":"https://www.caniemail.com/tests/css-pseudo-class-default.html",
"test_results_url":"https://testi.at/proj/7ov4sbxz1krv07gkf5",
"stats":{"apple-mail":{"macos":{"12":"y","26":"y"},"ios":{"11":"y","26":"y"}},"gmail":{"desktop-webmail":{"2026-02":"n"},"ios":{"2026-02":"n"},"android":{"2026-02":"n"},"mobile-webmail":{"2026-02":"n"}},"orange":{"desktop-webmail":{"2026-02":"u"},"ios":{"2026-02":"u"},"android":{"2026-02":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2024":"n"},"windows-mail":{"2026-02":"n"},"macos":{"16.105.2":"y"},"outlook-com":{"2026-02":"a #1"},"ios":{"2026-02":"y"},"android":{"2026-02":"y"}},"samsung-email":{"android":{"6.0":"u"}},"sfr":{"desktop-webmail":{"2026-02":"u"},"ios":{"2026-02":"u"},"android":{"2026-02":"u"}},"thunderbird":{"macos":{"147.0.1":"y"}},"aol":{"desktop-webmail":{"2026-02":"y"},"ios":{"2026-02":"y"},"android":{"2026-02":"y"}},"yahoo":{"desktop-webmail":{"2026-02":"y"},"ios":{"2026-02":"y"},"android":{"2026-02":"y"}},"protonmail":{"desktop-webmail":{"2026-02":"n"},"ios":{"2026-02":"n"},"android":{"2026-02":"n"}},"hey":{"desktop-webmail":{"2026-02":"u"}},"mail-ru":{"desktop-webmail":{"2026-02":"y"}},"fastmail":{"desktop-webmail":{"2026-02":"u"}},"laposte":{"desktop-webmail":{"2026-02":"u"}},"free-fr":{"desktop-webmail":{"2026-02":"u"}},"t-online-de":{"desktop-webmail":{"2026-02":"n"}},"gmx":{"desktop-webmail":{"2026-02":"n"},"ios":{"2026-02":"y"},"android":{"2026-02":"n"}},"web-de":{"desktop-webmail":{"2026-02":"n"},"ios":{"2026-02":"u"},"android":{"2026-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-02":"u"},"android":{"2026-02":"u"}}},
"notes":"Depends on device/browser support. Some devices/browsers do not support styling on &lt;option&gt;.",
"notes_by_num":{"1":"Works on input[radio] and input[checkbox] only."}
},
{
"slug":"css-pseudo-class-first-child",
"title":":first-child",
@@ -2558,7 +2574,7 @@
"last_test_date":"2022-07-21",
"test_url":"https://www.caniemail.com/tests/css-tab-size.html",
"test_results_url":"https://testi.at/proj/Rk9H1m9ubAYH1DwUqZu8G",
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"u"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"y #2"},"outlook-com":{"2022-07":"y #2","2024-01":"y #2"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"n #1"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"n"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"n"}}},
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"u"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"y #2"},"outlook-com":{"2022-07":"y #2","2024-01":"y #2"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"n #1"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"n"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"n"}}},
"notes":null,
"notes_by_num":{"1":"Supports `tab-size` but doesn't support `white-space`. Therefore, `tab-size` is not effectively visible","2":"Supports `tab-size` but strips the tab character `&#0009;`"}
},
@@ -2585,7 +2601,7 @@
"description":"The `text-align-last` CSS property sets how the last line of a block or a line right before a forced line break is aligned.",
"url":"https://www.caniemail.com/features/css-text-align-last/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"align, align-last",
"last_test_date":"2022-08-31",
"test_url":"https://www.caniemail.com/tests/css-text-align-last.html",
@@ -2601,7 +2617,7 @@
"description":"Sets the horizontal alignment of the content.",
"url":"https://www.caniemail.com/features/css-text-align/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"align",
"last_test_date":"2021-09-24",
"test_url":"https://www.caniemail.com/tests/css-text-align.html",
@@ -2697,7 +2713,7 @@
"description":"Tested with the values `overline`, `underline` and `line-through`.",
"url":"https://www.caniemail.com/features/css-text-decoration/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"underline",
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-text-decoration.html",
@@ -2825,7 +2841,7 @@
"description":"Each of the six `text-transform` values defined by MDN (`capitalize`, `uppercase`, `lowercase`, `none`, `full-width`, `full-size-kana`).",
"url":"https://www.caniemail.com/features/css-text-transform/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":null,
"last_test_date":"2021-09-19",
"test_url":"https://www.caniemail.com/tests/css-text-transform.html",
@@ -3198,9 +3214,9 @@
"last_test_date":"2024-02-14",
"test_url":"https://www.caniemail.com/tests/css-user-select.html",
"test_results_url":"https://testi.at/proj/9zjptajgcxyzc74ockp",
"stats":{"apple-mail":{"macos":{"2024-02":"y #1"},"ios":{"2024-02":"a #1 #2"}},"gmail":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n #3"},"android":{"2024-02":"n"},"mobile-webmail":{"2024-02":"u"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-02":"n"},"macos":{"2024-02":"n #3"},"outlook-com":{"2024-02":"n #3"},"ios":{"2024-02":"n #3"},"android":{"2024-02":"n"}},"yahoo":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"aol":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"samsung-email":{"android":{"2024-02":"y"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"u"}},"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":"n #3"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-02":"y #1"},"ios":{"2024-02":"a #1 #2"}},"gmail":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n #3"},"android":{"2024-02":"n"},"mobile-webmail":{"2024-02":"u"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-02":"n"},"macos":{"2024-02":"n #3"},"outlook-com":{"2024-02":"n #3"},"ios":{"2024-02":"n #3"},"android":{"2024-02":"n"}},"yahoo":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"aol":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"samsung-email":{"android":{"2024-02":"y"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"u"}},"protonmail":{"desktop-webmail":{"2024-02":"a #2 #4"},"ios":{"2024-02":"a #2 #4"},"android":{"2024-02":"a #2 #4"}},"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":"n #3"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"n #3"},"ios":{"2024-02":"n"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
"notes":null,
"notes_by_num":{"1":"Works with `-webkit` prefix","2":"`all` value does not work","3":"Property is stripped from style tag"}
"notes_by_num":{"1":"Works with `-webkit` prefix","2":"`all` value does not work","3":"Property is stripped from style tag","4":"`none` value does not work, client allow to select text anyway"}
},
{
@@ -3262,7 +3278,7 @@
"last_test_date":"2024-09-04",
"test_url":"https://www.caniemail.com/tests/css-white-space-collapse.html",
"test_results_url":"https://testi.at/proj/e6y4s3zytp5kty7kcg",
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"n","13":"n","14":"y #1"},"ios":{"15":"y #1"}},"gmail":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"},"mobile-webmail":{"2024-09":"n"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"n"},"outlook-com":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"yahoo":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"aol":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"samsung-email":{"android":{"2024-09":"y #1"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y #1"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}}},
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"n","13":"n","14":"y #1"},"ios":{"15":"y #1"}},"gmail":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"},"mobile-webmail":{"2024-09":"n"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"n"},"outlook-com":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"yahoo":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"aol":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"samsung-email":{"android":{"2024-09":"y #1"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"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 #1"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. `preserve-spaces` value works only on Firefox."}
},
@@ -3294,7 +3310,7 @@
"last_test_date":"2024-05-03",
"test_url":"https://www.caniemail.com/tests/css-widows.html",
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"y #4"},"ios":{"2024-05":"y #4"},"android":{"2024-05":"y #4"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `widows` to work","2":"Buggy. `widows` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `widows` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
},
@@ -3625,7 +3641,7 @@
"description":null,
"url":"https://www.caniemail.com/features/html-blockquote/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2020-05-08",
"test_url":"https://www.caniemail.com/tests/html-semantics.html",
@@ -3694,7 +3710,7 @@
"last_test_date":"2024-05-01",
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -3710,7 +3726,7 @@
"last_test_date":"2024-05-01",
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -3769,7 +3785,7 @@
"description":"It is used to identify a term that is going to be described within the content.",
"url":"https://www.caniemail.com/features/html-dfn/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2023-09-11",
"test_url":"https://www.caniemail.com/tests/html-dfn.html",
@@ -3865,7 +3881,7 @@
"description":"Support for headings elements in HTML: `<h1>`, `<h2>`, `<h3>`, `<h4>`, `<h5>`, `<h6>`.",
"url":"https://www.caniemail.com/features/html-h1-h6/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":"headings, h1, h2, h3, h4, h5, h6",
"last_test_date":"2020-05-08",
"test_url":"https://www.caniemail.com/tests/html-semantics.html",
@@ -4089,7 +4105,7 @@
"description":"Support for lists in HTML: `<ul>`, `<ol>`, `<li>`, `<dl>`, `<dt>` and `<dd>` elements.",
"url":"https://www.caniemail.com/features/html-lists/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":"ul, ol, li, dl, dt, dd",
"last_test_date":"2024-02-17",
"test_url":"https://www.caniemail.com/tests/css-list.html",
@@ -4153,7 +4169,7 @@
"description":"Changes the default colors of HTML elements. Useful for when you want an email to display only in a dark color scheme or only a light scheme, regardless of user settings. Equivalent to setting the color-scheme CSS property on the root element",
"url":"https://www.caniemail.com/features/html-meta-color-scheme/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":"dark-mode",
"last_test_date":"2023-09-18",
"test_url":"https://www.caniemail.com/tests/html-meta-color-scheme.html",
@@ -4201,7 +4217,7 @@
"description":null,
"url":"https://www.caniemail.com/features/html-p/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":"paragraph",
"last_test_date":"2020-05-08",
"test_url":"https://www.caniemail.com/tests/html-semantics.html",
@@ -4345,7 +4361,7 @@
"description":"",
"url":"https://www.caniemail.com/features/html-ruby/",
"category":"html",
"tags":["i18n"],
"tags":["i18n","accessibility"],
"keywords":null,
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/HTML5.html",
@@ -4377,7 +4393,7 @@
"description":"This includes support for `<article>`, `<aside>`, `<details>`, `<figcaption>`, `<figure>`, `<footer>`, `<header>`, `<main>`, `<mark>`, `<nav>`, `<section>`, `<summary>`, `<time>` elements.",
"url":"https://www.caniemail.com/features/html-semantics/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":"article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
"last_test_date":"2019-07-29",
"test_url":"https://www.caniemail.com/tests/html-semantics.html",
@@ -4505,7 +4521,7 @@
"description":null,
"url":"https://www.caniemail.com/features/html-table/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2019-09-15",
"test_url":"https://www.caniemail.com/tests/html-table.html",

View File

@@ -42,19 +42,19 @@ type CanIEmail struct {
// JSONResult struct for CanIEmail Data
type JSONResult struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Category string `json:"category"`
Tags []string `json:"tags"`
Keywords string `json:"keywords"`
LastTestDate string `json:"last_test_date"`
TestURL string `json:"test_url"`
TestResultsURL string `json:"test_results_url"`
Stats map[string]interface{} `json:"stats"`
Notes string `json:"notes"`
NotesByNumber map[string]string `json:"notes_by_num"`
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Category string `json:"category"`
Tags []string `json:"tags"`
Keywords string `json:"keywords"`
LastTestDate string `json:"last_test_date"`
TestURL string `json:"test_url"`
TestResultsURL string `json:"test_results_url"`
Stats map[string]any `json:"stats"`
Notes string `json:"notes"`
NotesByNumber map[string]string `json:"notes_by_num"`
}
// Load the JSON data

View File

@@ -72,7 +72,7 @@ func TestInlineStyleDetection(t *testing.T) {
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}

View File

@@ -141,11 +141,11 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
continue
}
for platform, clients := range stats.(map[string]interface{}) {
for platform, clients := range stats.(map[string]any) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]interface{}) {
for version, support := range clients.(map[string]any) {
s := Result{}
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
s.Family = family

View File

@@ -1,7 +1,7 @@
package htmlcheck
import (
"sort"
"slices"
"github.com/axllent/mailpit/internal/tools"
)
@@ -18,7 +18,7 @@ func Platforms() (map[string][]string, error) {
for _, t := range cie.Data {
for family, stats := range t.Stats {
niceFamily := cie.NiceNames.Family[family]
for platform := range stats.(map[string]interface{}) {
for platform := range stats.(map[string]any) {
c, found := data[platform]
if !found {
data[platform] = []string{}
@@ -32,9 +32,7 @@ func Platforms() (map[string][]string, error) {
}
for group, clients := range data {
sort.Slice(clients, func(i, j int) bool {
return clients[i] < clients[j]
})
slices.Sort(clients)
data[group] = clients
}

View File

@@ -1,14 +1,20 @@
package linkcheck
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
)
func getHTTPStatuses(links []string, followRedirects bool) []Link {
@@ -34,6 +40,10 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
if err != nil {
l.StatusCode = 0
l.Status = httpErrorSummary(err)
if strings.Contains(l.Status, "private/reserved address") {
l.Status = "Blocked private/reserved address"
l.StatusCode = 451
}
} else {
l.StatusCode = code
l.Status = http.StatusText(code)
@@ -57,23 +67,38 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
// Do a HEAD request to return HTTP status code
func doHead(link string, followRedirects bool) (int, error) {
if !tools.IsValidLinkURL(link) {
return 0, fmt.Errorf("invalid URL: %s", link)
}
timeout := time.Duration(10 * time.Second)
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
tr := &http.Transport{}
tr := &http.Transport{
DialContext: safeDialContext(dialer),
}
if config.AllowUntrustedTLS {
// user has explicitly allowed untrusted TLS, so we will not verify it for link checks
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
}
client := http.Client{
Timeout: timeout,
Timeout: 10 * time.Second,
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if followRedirects {
return nil
if len(via) >= 3 {
return errors.New("too many redirects")
}
return http.ErrUseLastResponse
if !followRedirects {
return http.ErrUseLastResponse
}
if !tools.IsValidLinkURL(req.URL.String()) {
return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL)
}
return nil
},
}
@@ -92,7 +117,6 @@ func doHead(link string, followRedirects bool) (int, error) {
}
return 0, err
}
return res.StatusCode, nil
@@ -107,8 +131,33 @@ func httpErrorSummary(err error) string {
if !re.MatchString(e) {
return e
}
parts := re.FindAllStringSubmatch(e, -1)
return parts[0][len(parts[0])-1]
}
// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.
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) {
logger.Log().Warnf("[link-check] Blocked HEAD request to private/reserved address: %s (%s)", host, 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

@@ -61,7 +61,7 @@ func Log() *logrus.Logger {
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
func PrettyPrint(i any) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}

View File

@@ -362,11 +362,11 @@ func randRange(min, max int) int {
}
func insertEmailData(t *testing.T) {
for i := 0; i < 50; i++ {
for i := range 50 {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
@@ -397,7 +397,7 @@ func insertEmailData(t *testing.T) {
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}

View File

@@ -138,7 +138,7 @@ func (c *Conn) Send(b string) error {
// in case of single line responses, or a help message followed by multiple lines of actual response
// data in case of multiline responses.
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
func (c *Conn) Cmd(cmd string, isMulti bool, args ...interface{}) (*bytes.Buffer, error) {
func (c *Conn) Cmd(cmd string, isMulti bool, args ...any) (*bytes.Buffer, error) {
var cmdLine string
// Repeat a %v to format each arg.
@@ -441,12 +441,12 @@ func parseResp(b []byte) ([]byte, error) {
if bytes.Equal(b, respOK) {
return nil, nil
} else if bytes.HasPrefix(b, respOKInfo) {
return bytes.TrimPrefix(b, respOKInfo), nil
} else if after, ok := bytes.CutPrefix(b, respOKInfo); ok {
return after, nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if bytes.HasPrefix(b, respErrInfo) {
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
} else if after, ok := bytes.CutPrefix(b, respErrInfo); ok {
return nil, errors.New(string(after))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))

View File

@@ -108,9 +108,9 @@ func forward(from string, msg []byte) error {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
to := strings.Split(config.SMTPForwardConfig.To, ",")
to := strings.SplitSeq(config.SMTPForwardConfig.To, ",")
for _, addr := range to {
for addr := range to {
if err = c.Rcpt(addr); err != nil {
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
if config.SMTPForwardConfig.ForwardSMTPErrors {

View File

@@ -337,7 +337,7 @@ func (srv *Server) Shutdown(ctx context.Context) error {
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for i := 0; i < 300; i++ {
for range 300 {
// wait for open sessions to close
if atomic.LoadInt32(&srv.openSessions) == 0 {
break
@@ -636,8 +636,8 @@ loop:
case "XCLIENT":
s.xClient = args
if s.xClientTrust {
xCArgs := strings.Split(args, " ")
for _, xCArg := range xCArgs {
xCArgs := strings.SplitSeq(args, " ")
for xCArg := range xCArgs {
xCParse := strings.Split(strings.TrimSpace(xCArg), "=")
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
s.xClientADDR = xCParse[1]
@@ -786,7 +786,7 @@ loop:
}
// Wrapper function for writing a complete line to the socket.
func (s *session) writef(format string, args ...interface{}) {
func (s *session) writef(format string, args ...any) {
if s.srv.Timeout > 0 {
_ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))
}
@@ -831,9 +831,9 @@ func (s *session) readLine() (string, error) {
// Parse a line read from the socket.
func (s *session) parseLine(line string) (verb string, args string) {
if idx := strings.Index(line, " "); idx != -1 {
verb = strings.ToUpper(line[:idx])
args = strings.TrimSpace(line[idx+1:])
if before, after, ok := strings.Cut(line, " "); ok {
verb = strings.ToUpper(before)
args = strings.TrimSpace(after)
} else {
verb = strings.ToUpper(line)
args = ""
@@ -928,6 +928,10 @@ func (s *session) makeEHLOResponse() (response string) {
}
response += "250-ENHANCEDSTATUSCODES\r\n"
// RFC 6531 specifies that the presence of SMTPUTF8 should include 8BITMIME
// "Servers offering this extension MUST provide support for, and announce, the 8BITMIME extension"
// https://www.rfc-editor.org/rfc/rfc6531#section-3.1:
response += "250-8BITMIME\r\n"
response += "250 SMTPUTF8" // last entry must use a space instead of a dash
return
}

View File

@@ -215,10 +215,10 @@ func TestCmdMAIL(t *testing.T) {
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE= ", "501")
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE=foo", "501")
// MAIL with options should be ignored except for SIZE
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250") // ignored
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=1000", "250") // size detected
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=foo", "501") // ignored
// MAIL with BODY parameter should be accepted (8BITMIME support)
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250")
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=1000", "250")
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=foo", "501") // SIZE validation error
// TODO: MAIL with valid AUTH parameter should return 250 Ok
@@ -779,8 +779,8 @@ func parseExtensions(t *testing.T, greeting string) map[string]string {
// Add line as extension.
line = strings.TrimSpace(line[4:]) // Strip code prefix and trailing \r\n
if idx := strings.Index(line, " "); idx != -1 {
extensions[line[:idx]] = line[idx+1:]
if before, after, ok := strings.Cut(line, " "); ok {
extensions[before] = after
} else {
extensions[line] = ""
}
@@ -884,6 +884,105 @@ func TestMakeEHLOResponse(t *testing.T) {
if !rePlain.MatchString(extensions["AUTH"]) {
t.Errorf("AUTH mechanism PLAIN does not appear in the extension list when an AuthHandler is specified and TLS is in use")
}
// 8BITMIME should always be advertised
s.srv = &Server{}
s.tls = false
extensions = parseExtensions(t, s.makeEHLOResponse())
if _, ok := extensions["8BITMIME"]; !ok {
t.Errorf("8BITMIME does not appear in the extension list")
}
// SMTPUTF8 should always be advertised
if _, ok := extensions["SMTPUTF8"]; !ok {
t.Errorf("SMTPUTF8 does not appear in the extension list")
}
// ENHANCEDSTATUSCODES should always be advertised
if _, ok := extensions["ENHANCEDSTATUSCODES"]; !ok {
t.Errorf("ENHANCEDSTATUSCODES does not appear in the extension list")
}
}
// Test 8BITMIME BODY parameter parsing in MAIL FROM command
func TestCmd8BITMIME(t *testing.T) {
srv := &Server{}
conn := newConn(t, srv)
cmdCode(t, conn, "EHLO host.example.com", "250")
// Create a session to check internal state
clientConn, serverConn := net.Pipe()
session := srv.newSession(serverConn)
go session.serve()
// Read and discard banner
_, _ = bufio.NewReader(clientConn).ReadString('\n')
// Send EHLO
_, _ = fmt.Fprintf(clientConn, "EHLO test.example.com\r\n")
reader := bufio.NewReader(clientConn)
for {
line, _ := reader.ReadString('\n')
if strings.HasPrefix(line, "250 ") {
break
}
}
// Test BODY=8BITMIME parameter
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> BODY=8BITMIME\r\n")
resp, _ := reader.ReadString('\n')
if !strings.HasPrefix(resp, "250") {
t.Errorf("MAIL FROM with BODY=8BITMIME failed: %s", resp)
}
// Verify bodyEncoding was set (we can't directly access it, but we can test the behavior)
// Reset and test BODY=7BIT
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
_, _ = reader.ReadString('\n')
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> BODY=7BIT\r\n")
resp, _ = reader.ReadString('\n')
if !strings.HasPrefix(resp, "250") {
t.Errorf("MAIL FROM with BODY=7BIT failed: %s", resp)
}
// Test BODY parameter with SIZE parameter
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
_, _ = reader.ReadString('\n')
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> SIZE=1000 BODY=8BITMIME\r\n")
resp, _ = reader.ReadString('\n')
if !strings.HasPrefix(resp, "250") {
t.Errorf("MAIL FROM with SIZE and BODY parameters failed: %s", resp)
}
// Test case insensitivity
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
_, _ = reader.ReadString('\n')
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> body=8bitmime\r\n")
resp, _ = reader.ReadString('\n')
if !strings.HasPrefix(resp, "250") {
t.Errorf("MAIL FROM with lowercase body parameter failed: %s", resp)
}
// Clean up
_, _ = fmt.Fprintf(clientConn, "QUIT\r\n")
_, _ = reader.ReadString('\n')
_ = clientConn.Close()
// Also test via the original connection
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250")
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
cmdCode(t, conn, "RSET", "250")
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=7BIT", "250")
cmdCode(t, conn, "RSET", "250")
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME SIZE=5000", "250")
cmdCode(t, conn, "QUIT", "221")
_ = conn.Close()
}
// func createTmpFile(content string) (file *os.File, err error) {

View File

@@ -128,7 +128,10 @@ func pruneMessages() {
return
}
args := make([]interface{}, len(ids))
// roll back if it fails
defer func() { _ = tx.Rollback() }()
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
@@ -151,13 +154,8 @@ func pruneMessages() {
return
}
err = tx.Commit()
if err != nil {
if err = tx.Commit(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
if err := pruneUnusedTags(); err != nil {

View File

@@ -110,7 +110,7 @@ func InitDB() error {
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
time.Sleep(5 * time.Second)
} else {
continue
break
}
}

View File

@@ -49,7 +49,7 @@ func setup(tenantID string) {
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}

View File

@@ -656,7 +656,7 @@ func DeleteMessages(ids []string) error {
start := time.Now()
args := make([]interface{}, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
@@ -696,7 +696,7 @@ func DeleteMessages(ids []string) error {
return err
}
args = make([]interface{}, len(toDelete))
args = make([]any, len(toDelete))
for i, id := range toDelete {
args[i] = id
}

View File

@@ -16,7 +16,7 @@ func TestTextEmailInserts(t *testing.T) {
start := time.Now()
for i := 0; i < testRuns; i++ {
for range testRuns {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
@@ -54,7 +54,7 @@ func TestMimeEmailInserts(t *testing.T) {
start := time.Now()
for i := 0; i < testRuns; i++ {
for range testRuns {
if _, err := Store(&testMimeEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()

View File

@@ -80,10 +80,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
end := min(nrResults, start+limit)
results = allResults[start:end]
}
@@ -196,7 +193,7 @@ func DeleteSearch(search, timezone string) error {
defer func() { _ = tx.Rollback() }()
for _, ids := range chunks {
delIDs := make([]interface{}, len(ids))
delIDs := make([]any, len(ids))
for i, id := range ids {
delIDs[i] = id
}
@@ -303,12 +300,12 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
loc := time.Local
if timezone != "" {
loc, err := time.LoadLocation(timezone)
if err != nil {
if l, err := time.LoadLocation(timezone); err != nil {
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
} else {
time.Local = loc
loc = l
}
}
@@ -440,9 +437,9 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
}
} else if lw == "is:tagged" {
if exclude {
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
} else {
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
}
} else if lw == "has:inline" || lw == "has:inlines" {
if exclude {
@@ -459,7 +456,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
} else if strings.HasPrefix(lw, "after:") {
w = cleanString(w[6:])
if w != "" {
t, err := dateparse.ParseLocal(w)
t, err := dateparse.ParseIn(w, loc)
if err != nil {
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
} else {
@@ -474,7 +471,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
if w != "" {
t, err := dateparse.ParseLocal(w)
t, err := dateparse.ParseIn(w, loc)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
} else {

View File

@@ -3,7 +3,7 @@ package storage
import (
"bytes"
"fmt"
"math/rand"
"math/rand/v2"
"testing"
"github.com/axllent/mailpit/config"
@@ -22,13 +22,13 @@ func TestSearch(t *testing.T) {
t.Logf("Testing search (tenant %s)", tenantID)
}
for i := 0; i < testRuns; i++ {
for i := range testRuns {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)).
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
@@ -73,7 +73,7 @@ func TestSearch(t *testing.T) {
fmt.Sprintf("subject:\"Subject line %d end\"", i),
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
}
searchIdx := rand.Intn(len(uniqueSearches))
searchIdx := rand.IntN(len(uniqueSearches))
search := uniqueSearches[searchIdx]
@@ -116,7 +116,7 @@ func TestSearchDelete100(t *testing.T) {
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
}
for i := 0; i < 100; i++ {
for range 100 {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
@@ -157,7 +157,7 @@ func TestSearchDelete1100(t *testing.T) {
defer Close()
t.Log("Testing search delete of 1100 messages")
for i := 0; i < 1100; i++ {
for range 1100 {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()

View File

@@ -3,6 +3,7 @@ package storage
import (
"context"
"fmt"
"slices"
"strings"
"testing"
@@ -25,7 +26,7 @@ func TestTags(t *testing.T) {
ids := []string{}
for i := 0; i < 10; i++ {
for range 10 {
id, err := Store(&testMimeEmail, nil)
if err != nil {
t.Log("error ", err)
@@ -34,14 +35,14 @@ func TestTags(t *testing.T) {
ids = append(ids, id)
}
for i := 0; i < 10; i++ {
for i := range 10 {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 0; i < 10; i++ {
for i := range 10 {
message, err := GetMessage(ids[i])
if err != nil {
t.Log("error ", err)
@@ -65,7 +66,7 @@ func TestTags(t *testing.T) {
t.Fail()
}
newTags := []string{}
for i := 0; i < 20; i++ {
for i := range 20 {
// pad number with 0 to ensure they are returned alphabetically
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
}
@@ -159,13 +160,7 @@ func TestUsernameAutoTagging(t *testing.T) {
if err != nil {
t.Fatalf("GetMessage failed: %v", err)
}
found := false
for _, tag := range msg.Tags {
if tag == username {
found = true
break
}
}
found := slices.Contains(msg.Tags, username)
if !found {
t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags)
}

28
internal/tools/net.go Normal file
View File

@@ -0,0 +1,28 @@
package tools
import (
"net"
"net/url"
)
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
// IsLoopback — 127.0.0.0/8, ::1
// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16
// IsUnspecified — 0.0.0.0, ::
// IsMulticast — 224.0.0.0/4, ff00::/8
func IsInternalIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast()
}
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
func IsValidLinkURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != ""
}

1373
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"lint-fix": "eslint --fix && prettier --write ."
},
"dependencies": {
"axios": "^1.11.0",
"axios": "^1.13.5",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
@@ -30,7 +30,8 @@
"vue-router": "^4.2.4"
},
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/compat": "^2.0.2",
"@eslint/js": "^10.0.1",
"@popperjs/core": "^2.11.5",
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
@@ -38,9 +39,10 @@
"esbuild": "^0.27.2",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0",
"eslint": "^9.29.0",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^10.2.0",
"globals": "^17.3.0",
"prettier": "^3.5.3"
},
"prettier": {

View File

@@ -90,7 +90,13 @@ func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
}
defer func() { _ = c.Close() }()
if err = c.Hello(addr); err != nil {
// Use the local hostname for EHLO/HELO as required by RFC 5321.
// Fall back to "localhost" if the hostname cannot be determined.
localHostname, err := os.Hostname()
if err != nil {
localHostname = "localhost"
}
if err = c.Hello(localHostname); err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/mail"
"net/url"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
@@ -158,7 +159,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
w.Header().Set("Content-Disposition", "inline; filename=\""+url.PathEscape(fileName)+"\"")
_, _ = w.Write(a.Content)
}

View File

@@ -43,20 +43,25 @@ func corsOriginAccessControl(r *http.Request) bool {
if len(origin) != 0 {
u, err := url.Parse(origin[0])
if err != nil {
logger.Log().Errorf("CORS origin parse error: %v", err)
logger.Log().Errorf("[cors] origin parse error: %v", err)
return false
}
_, allAllowed := corsAllowOrigins["*"]
// allow same origin || is "*" is defined as an origin
// allow same origin, or if "*" is defined as an origin
if asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed {
return true
}
originHostFold := asciiFoldString(u.Hostname())
// match on full host:port so that example.com:8080 is not admitted
// by an allowlist entry for example.com (standard port 80/443).
originHostFold := asciiFoldString(u.Host)
if corsAllowOrigins[originHostFold] {
return true
}
logger.Log().Warnf("[cors] blocking request from unauthorized origin: %s", u.Host)
return false
}
@@ -64,7 +69,8 @@ func corsOriginAccessControl(r *http.Request) bool {
}
// SetCORSOrigins sets the allowed CORS origins from a comma-separated string.
// It does not consider port or protocol, only the hostname.
// Origins are matched on the full host:port, so example.com and example.com:8080
// are treated as distinct origins.
func setCORSOrigins() {
corsAllowOrigins = make(map[string]bool)
@@ -117,7 +123,9 @@ func extractOrigins(str string) []string {
continue
}
origins = append(origins, u.Hostname())
// Store host:port so port differences are respected.
// u.Host equals u.Hostname() when no port is present.
origins = append(origins, u.Host)
}
}

View File

@@ -39,7 +39,7 @@ func TestExtractOrigins(t *testing.T) {
{
name: "mixed protocols",
input: "http://example.com,https://foo.com:8080",
expected: []string{"example.com", "foo.com"},
expected: []string{"example.com", "foo.com:8080"},
},
{
@@ -78,7 +78,10 @@ func TestCorsOriginAccessControl(t *testing.T) {
allow bool
}{
{"no origin header", "", "example.com", true},
{"allowed origin", "http://example.com:1234", "mailpit.local", true},
// example.com:1234 must NOT be admitted by an allowlist entry for example.com (different port)
{"allowed origin", "http://example.com:1234", "mailpit.local", false},
{"allowed origin", "http://example.com:1234", "example.com", false},
{"allowed origin", "http://example.com:1234", "example.com:1234", true},
{"not allowed origin", "http://notallowed.com", "mailpit.local", false},
{"allowed by hostname", "http://foo.com", "mailpit.local", true},
{"ascii fold: allowed origin uppercase", "HTTP://EXAMPLE.COM", "mailpit.local", true},

View File

@@ -2,10 +2,13 @@
package handlers
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@@ -20,6 +23,12 @@ import (
"github.com/axllent/mailpit/internal/tools"
)
const (
// maxProxyBodySize is the maximum number of bytes read from a proxied
// response body (fonts, images, CSS). Prevents OOM on oversized responses.
maxProxyBodySize = 50 * 1024 * 1024 // 50 MB
)
var (
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
@@ -96,21 +105,37 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !linkRe.MatchString(uri) {
logger.Log().Warnf("[proxy] invalid request %s", uri)
httpError(w, "Error: invalid request")
if !linkRe.MatchString(uri) || !tools.IsValidLinkURL(uri) {
logger.Log().Warnf("[proxy] invalid URL %s", uri)
httpError(w, "Error: invalid URL")
return
}
tr := &http.Transport{}
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
tr := &http.Transport{
DialContext: safeDialContext(dialer),
}
if config.AllowUntrustedTLS {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
}
client := &http.Client{
Transport: tr,
Timeout: 10 * time.Second,
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
if !tools.IsValidLinkURL(req.URL.String()) {
return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL)
}
return nil
},
}
req, err := http.NewRequest("GET", uri, nil)
@@ -145,12 +170,18 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
return
}
body, err := io.ReadAll(resp.Body)
limitedBody := io.LimitReader(resp.Body, maxProxyBodySize+1)
body, err := io.ReadAll(limitedBody)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, "Error: invalid request")
return
}
if int64(len(body)) > maxProxyBodySize {
logger.Log().Warnf("[proxy] response body for %s exceeds %d bytes, blocking", uri, maxProxyBodySize)
httpError(w, "Error: response too large")
return
}
// relay common headers
w.Header().Set("content-type", ct)
@@ -357,3 +388,28 @@ func supportedProxyContentType(ct string) bool {
return false
}
// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.
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

@@ -4,6 +4,7 @@ package server
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net"
@@ -36,6 +37,13 @@ var (
htmlPreviewRouteRe *regexp.Regexp
)
// skipUIAuthKey is a private context key used to signal that UI basic-auth
// should be bypassed for a specific request. This avoids mutating the global
// auth.UICredentials pointer (which is a data race under concurrent load).
type contextKey int
const skipUIAuthKey contextKey = iota
// Listen will start the httpd
func Listen() {
setCORSOrigins()
@@ -198,7 +206,7 @@ func apiRoutes() *mux.Router {
}
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
r.HandleFunc(config.Webroot+"api/events", middleWareFunc(apiWebsocket)).Methods("GET")
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
@@ -210,25 +218,23 @@ func apiRoutes() *mux.Router {
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorised.\n"))
_, _ = w.Write([]byte("Unauthorized.\n"))
}
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
// It can use dedicated send API authentication or accept any credentials based on configuration
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint.
// It can use dedicated send API authentication or accept any credentials based on configuration.
// It communicates skip-UI-auth intent via request context rather than mutating the global
// auth.UICredentials pointer, which would be a data race under concurrent load.
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// If send API auth accept any is enabled, bypass all authentication
// If send API auth accept any is enabled, bypass all authentication.
if config.SendAPIAuthAcceptAny {
// Temporarily disable UI auth for this request
originalCredentials := auth.UICredentials
auth.UICredentials = nil
defer func() { auth.UICredentials = originalCredentials }()
// Call the standard middleware
middleWareFunc(fn)(w, r)
ctx := context.WithValue(r.Context(), skipUIAuthKey, true)
middleWareFunc(fn)(w, r.WithContext(ctx))
return
}
// If Send API credentials are configured, only accept those credentials
// If Send API credentials are configured, only accept those credentials.
if auth.SendAPICredentials != nil {
user, pass, ok := r.BasicAuth()
@@ -242,15 +248,13 @@ func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return
}
// Valid Send API credentials - bypass UI auth and call function directly
originalCredentials := auth.UICredentials
auth.UICredentials = nil
defer func() { auth.UICredentials = originalCredentials }()
middleWareFunc(fn)(w, r)
// Valid Send API credentials bypass UI auth via context flag.
ctx := context.WithValue(r.Context(), skipUIAuthKey, true)
middleWareFunc(fn)(w, r.WithContext(ctx))
return
}
// No Send API credentials configured - fall back to UI auth
// No Send API credentials configured fall back to UI auth.
middleWareFunc(fn)(w, r)
}
}
@@ -291,7 +295,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
if strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI) {
if allowed := corsOriginAccessControl(r); !allowed {
http.Error(w, "Unauthorised.", http.StatusForbidden)
http.Error(w, "Blocked to to CORS violation", http.StatusForbidden)
return
}
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
@@ -301,8 +305,11 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
// Check basic authentication headers if configured.
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight checks.
// skipUIAuthKey in the request context allows sendAPIAuthMiddleware to bypass UI auth
// for a specific request without touching the global auth.UICredentials pointer.
skipUIAuth, _ := r.Context().Value(skipUIAuthKey).(bool)
isCORSOptionsRequest := AccessControlAllowOrigin != "" && r.Method == http.MethodOptions
if !isCORSOptionsRequest && auth.UICredentials != nil {
if !skipUIAuth && !isCORSOptionsRequest && auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -316,7 +323,11 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
}
}
if config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
// WebSocket upgrade requests must not be wrapped in a gzip writer:
// gzipResponseWriter does not implement http.Hijacker, which the
// WebSocket library requires to take over the raw TCP connection.
isWebSocketUpgrade := strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
if isWebSocketUpgrade || config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r)
return
}
@@ -334,14 +345,9 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// Websocket to broadcast changes
// Websocket to broadcast changes.
// Authentication and CORS are handled by middleWareFunc before this is reached.
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
if allowed := corsOriginAccessControl(r); !allowed {
http.Error(w, "Unauthorised.", http.StatusForbidden)
return
}
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Headers", "*")
websockets.ServeWs(websockets.MessageHub, w, r)
storage.BroadcastMailboxStats()
}

View File

@@ -28,7 +28,7 @@ var (
}
// Shared test message structure for consistency
testSendMessage = map[string]interface{}{
testSendMessage = map[string]any{
"From": map[string]string{
"Email": "test@example.com",
},
@@ -545,11 +545,11 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
}
func insertEmailData(t *testing.T) {
for i := 0; i < 100; i++ {
for i := range 100 {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
@@ -754,7 +754,7 @@ func clientGetWithAuth(url, username, password string) ([]byte, error) {
return data, err
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}

View File

@@ -25,6 +25,13 @@ export default {
};
},
computed: {
isEdgeBuild() {
const re = /^(v\d+.\d+.\d+-)/i;
return re.test(mailbox.appInfo.Version);
},
},
methods: {
loadInfo() {
this.get(this.resolve("/api/v1/info"), false, (response) => {
@@ -98,6 +105,7 @@ export default {
<h5 id="AppInfoModalLabel" class="modal-title">
Mailpit
<code>({{ mailbox.appInfo.Version }})</code>
<span v-if="isEdgeBuild" class="badge bg-info text-dark ms-2">edge build</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>

View File

@@ -77,7 +77,8 @@ export default {
if (!this.pauseNotifications) {
this.pauseNotifications = true;
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
this.browserNotify("New mail from: " + from, response.Data.Subject);
const subject = String(response.Data.Subject ?? "").substring(0, 100);
this.browserNotify("New mail from: " + from, subject);
this.setMessageToast(response.Data);
// delay notifications by 2s
window.setTimeout(() => {

View File

@@ -14,9 +14,7 @@ export default {
timezones,
chaosConfig: false,
chaosUpdated: false,
defaultReleaseAddressesOptions: localStorage.getItem("defaultReleaseAddresses")
? JSON.parse(localStorage.getItem("defaultReleaseAddresses"))
: [], // set with default release addresses
defaultReleaseAddressesOptions: mailbox.defaultReleaseAddresses.slice(), // set with default release addresses
};
},

View File

@@ -89,7 +89,7 @@ export default {
<button
v-if="mailbox.skipConfirmations"
class="list-group-item list-group-item-action"
:disabled="!mailbox.messages_unread"
:disabled="!mailbox.unread"
@click="markAllRead"
>
<i class="bi bi-eye-fill me-1"></i>
@@ -100,7 +100,7 @@ export default {
class="list-group-item list-group-item-action"
data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal"
:disabled="!mailbox.messages_unread"
:disabled="!mailbox.unread"
>
<i class="bi bi-eye-fill me-1"></i>
Mark all read

View File

@@ -51,8 +51,7 @@ export default {
// universal handler to delete current or selected messages
deleteMessages() {
let ids = [];
ids = JSON.parse(JSON.stringify(mailbox.selected));
const ids = JSON.parse(JSON.stringify(mailbox.selected));
if (!ids.length) {
return false;
}

View File

@@ -107,7 +107,7 @@ export default {
"vspace",
"xml:lang",
],
FORBID_ATTR: ["script"], // all JavaScript should be removed
FORBID_TAGS: ["script", "form"], // all JavaScript should be removed
ALLOW_UNKNOWN_PROTOCOLS: true, // allow link href protocols like myapp:// etc
});
@@ -288,9 +288,12 @@ export default {
});
},
// Convert plain text to HTML including anchor links
// Convert plain text to HTML including anchor links.
// Only <a> tags are permitted in the output (enforced by DOMPurify).
textToHTML(s) {
let html = s;
// Strip the Unicode placeholder characters used below so that attacker-
// controlled input cannot pre-inject fake HTML tags via those chars.
let html = s.replace(/(˱˱˱|ˠˠˠ|˲˲˲)/gu, "");
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
// recognize potential spaces in between the URL
@@ -320,7 +323,10 @@ export default {
.replace(/˲˲˲/g, ">")
.replace(/ˠˠˠ/g, '"');
return html;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["a"],
ALLOWED_ATTR: ["href", "target", "rel"],
});
},
},
};

View File

@@ -2,6 +2,7 @@
import AjaxLoader from "../AjaxLoader.vue";
import CommonMixins from "../../mixins/CommonMixins";
import { domToPng } from "modern-screenshot";
import DOMPurify from "dompurify";
export default {
components: {
@@ -41,18 +42,38 @@ export default {
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
// Sanitize HTML before writing to the temporary document.
// This removes <script>, <noscript>, inline event handlers (on*),
// SVG <animate>/<set> with xlink:href and other active content
// that manual tag removal would miss.
h = DOMPurify.sanitize(h, {
WHOLE_DOCUMENT: true,
FORCE_BODY: false,
ADD_TAGS: ["link", "meta", "o:p", "style"],
ADD_ATTR: [
"bordercolor",
"charset",
"content",
"hspace",
"http-equiv",
"itemprop",
"itemscope",
"itemtype",
"vertical-align",
"vlink",
"vspace",
"xml:lang",
"background", // needed for background= URL replacement below
],
FORBID_TAGS: ["script", "noscript"],
});
// create temporary document to manipulate
const doc = document.implementation.createHTMLDocument();
doc.open();
doc.writeln(h);
doc.close();
// remove any <script> tags
const scripts = doc.getElementsByTagName("script");
for (const i of scripts) {
i.parentNode.removeChild(i);
}
// replace any url(...) links in <style> blocks
const styles = doc.getElementsByTagName("style");
for (const i of styles) {
@@ -117,11 +138,7 @@ export default {
// HTML decode function
decodeEntities(s) {
const e = document.createElement("div");
e.innerHTML = s;
const str = e.textContent;
e.textContent = "";
return str;
return new DOMParser().parseFromString(s, "text/html").body.textContent;
},
doScreenshot() {
@@ -143,11 +160,18 @@ export default {
const body = i.contentWindow.document.querySelector("body");
// Add body padding to prevent content touching edge of screenshot.
body.style.padding = "20px";
// take screenshot of iframe
domToPng(body, {
backgroundColor: "#ffffff",
height: i.contentWindow.document.body.scrollHeight + 20,
height: i.contentWindow.document.body.scrollHeight,
width,
// remove the transparent 8px top and left gap from html object (default browser margins).
style: {
margin: "0",
},
}).then((dataUrl) => {
const link = document.createElement("a");
link.download = this.message.ID + ".png";

View File

@@ -2,6 +2,21 @@
import { reactive, watch } from "vue";
// Parse and validate a string[] from localStorage, returning [] on any invalid value.
const storageToStringArray = (key) => {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) {
return parsed;
}
} catch {
// ignore malformed JSON
}
return [];
};
// global mailbox info
export const mailbox = reactive({
total: 0, // total number of messages in database
@@ -20,9 +35,7 @@ export const mailbox = reactive({
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
defaultReleaseAddresses: localStorage.getItem("defaultReleaseAddresses")
? JSON.parse(localStorage.getItem("defaultReleaseAddresses"))
: [], // default release addresses for released messages
defaultReleaseAddresses: storageToStringArray("defaultReleaseAddresses"), // default release addresses for released messages
// settings
showTagColors: !localStorage.getItem("hideTagColors"),

View File

@@ -151,8 +151,9 @@ export default {
for (const i in d.Inline) {
const a = d.Inline[i];
if (a.ContentID !== "") {
const escapedCID = a.ContentID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
new RegExp("(=[\"']?)(cid:" + escapedCID + ")([\"'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}
@@ -171,8 +172,9 @@ export default {
for (const i in d.Attachments) {
const a = d.Attachments[i];
if (a.ContentID !== "") {
const escapedCID = a.ContentID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
new RegExp("(=[\"']?)(cid:" + escapedCID + ")([\"'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}

View File

@@ -67,19 +67,18 @@ func Send(msg any) {
req.Header.Set("Mailpit-Label", config.Label)
}
client := &http.Client{}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Log().Errorf("[webhook] error sending data: %s", err.Error())
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
logger.Log().Warnf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode)
return
}
_ = resp.Body.Close()
})
}()
}

View File

@@ -98,7 +98,7 @@ func (c *Client) writePump() {
// Add queued chat messages to the current websocket message.
n := len(c.send)
for i := 0; i < n; i++ {
for range n {
_, _ = w.Write(newline)
_, _ = w.Write(<-c.send)
}
@@ -147,5 +147,5 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorised.\n"))
_, _ = w.Write([]byte("Unauthorized.\n"))
}

View File

@@ -27,7 +27,7 @@ type Hub struct {
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
Data any
}
// NewHub returns a new hub configuration
@@ -69,7 +69,7 @@ func (h *Hub) Run() {
}
// Broadcast will spawn a broadcast message to all connected clients
func Broadcast(t string, msg interface{}) {
func Broadcast(t string, msg any) {
if MessageHub == nil || len(MessageHub.Clients) == 0 {
return
}