Compare commits

...

81 Commits

Author SHA1 Message Date
Ralph Slooten
294faa4f10 Merge branch 'release/v1.29.5' 2026-03-29 17:47:11 +13:00
Ralph Slooten
25b9ebd90e Release v1.29.5 2026-03-29 17:47:10 +13:00
Ralph Slooten
87472746a9 Chore: Update node dependencies 2026-03-29 17:41:37 +13:00
Ralph Slooten
9dd1e99f52 Fixes for eslint validation 2026-03-29 17:40:43 +13:00
Ralph Slooten
fcca56625f Chore: Update Go dependencies 2026-03-29 17:38:59 +13:00
Ralph Slooten
3a4c7766e9 Feature: Add option to disable auto-VACUUMing of the SQLite database (#661) 2026-03-29 17:29:02 +13:00
Ralph Slooten
dc9b8d54b7 Security: Add sandbox attribute to message iframe for extra later of security (already protected via CSP headers)
Note that this does not provide any additional security as such as the CSP headers do this, however it is another barrier when it comes to bypass attempts.
2026-03-28 08:01:51 +13:00
Ralph Slooten
b8cc1bc415 Fix typo 2026-03-26 07:47:20 +13:00
Ralph Slooten
0fee30d3df Enhance install script: Improve help output, error messages, and user feedback 2026-03-22 18:35:48 +13:00
Ralph Slooten
1200ad0506 Merge tag 'v1.29.4' into develop
Release v1.29.4
2026-03-22 17:56:59 +13:00
Ralph Slooten
c12c6458a3 Merge branch 'release/v1.29.4' 2026-03-22 17:56:53 +13:00
Ralph Slooten
16f0c1416d Release v1.29.4 2026-03-22 17:56:53 +13:00
Ralph Slooten
0e3441aba9 Chore: Update node dependencies 2026-03-22 17:52:31 +13:00
Ralph Slooten
2dc2145db7 Chore: Update Go dependencies 2026-03-22 17:48:59 +13:00
Ralph Slooten
9c2359eee5 Feature: Add filter functionality to message headers tab
This implementation is based on, and resolves, #626
2026-03-22 17:40:54 +13:00
Ralph Slooten
7b22d6a5f9 Fix: Refactor webhook delay & rate limit logic to ignore endpoint response times & prevent hardcoded 1000 message limit when set to 0 (#656) 2026-03-16 22:29:45 +13:00
Ralph Slooten
fcd964501a Merge tag 'v1.29.3' into develop
Release v1.29.3
2026-03-10 15:29:51 +13:00
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
Ralph Slooten
43b8ba3dc6 Merge branch 'release/v1.29.0' 2026-02-01 16:12:00 +13:00
Ralph Slooten
d41eca3df7 Release v1.29.0 2026-02-01 16:11:59 +13:00
Ralph Slooten
e6fd638067 Detect if copy to clipboard is supported 2026-02-01 16:09:49 +13:00
Ralph Slooten
e2b1b2d0fe Code cleanup 2026-02-01 15:58:31 +13:00
Ralph Slooten
9b4ec97483 Minor UI tweaks 2026-02-01 15:44:13 +13:00
Ralph Slooten
e735904167 Chore: Update node dependencies 2026-02-01 15:40:59 +13:00
Ralph Slooten
94113222cc Chore: Update Go dependencies 2026-02-01 15:37:40 +13:00
Ralph Slooten
5414695508 Test: Add message summary attachment checksum tests 2026-02-01 15:34:06 +13:00
Ralph Slooten
dd74d46880 Feature: Option to display/hide attachment information in message view in web UI including checksums, content type & disposition
Resolves #625
2026-02-01 15:34:06 +13:00
Ralph Slooten
0bfbb4cc5f Feature: Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary 2026-02-01 15:34:05 +13:00
Ralph Slooten
38c0c4fd47 Update webhook delay flag description 2026-02-01 15:34:05 +13:00
Roman Urbanovich
9391b075d0 Chore: Add support for webhook delay (#627) 2026-02-01 15:33:54 +13:00
Ralph Slooten
a87b2a9455 Update API CORS flag description 2026-02-01 15:33:53 +13:00
Ralph Slooten
8d18618e4a Test: Add CORS tests 2026-02-01 15:33:53 +13:00
Ralph Slooten
a63bcd9bd3 Chore: Add support for multi-origin CORS settings and apply to events websocket (#630) 2026-02-01 15:33:53 +13:00
62 changed files with 2102 additions and 1310 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,109 @@
Notable changes to Mailpit will be documented in this file.
## [v1.29.5]
### Security
- Add sandbox attribute to message iframe for extra later of security (already protected via CSP headers)
### Feature
- Add option to disable auto-VACUUMing of the SQLite database ([#661](https://github.com/axllent/mailpit/issues/661))
### Chore
- Update Go dependencies
- Update node dependencies
## [v1.29.4]
### Feature
- Add filter functionality to message headers tab
### Chore
- Update Go dependencies
- Update node dependencies
### Fix
- Refactor webhook delay & rate limit logic to ignore endpoint response times & prevent hardcoded 1000 message limit when set to 0 ([#656](https://github.com/axllent/mailpit/issues/656))
## [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
- Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary
- Option to display/hide attachment information in message view in web UI including checksums, content type & disposition
### Chore
- 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

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

@@ -86,6 +86,7 @@ func init() {
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
rootCmd.Flags().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
rootCmd.Flags().BoolVar(&config.DisableAutoVACUUM, "disable-auto-vacuum", config.DisableAutoVACUUM, "Disable auto-VACUUM for the database")
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
@@ -103,8 +104,9 @@ func init() {
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
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 API CORS Access-Control-Allow-Origin header")
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)")
@@ -160,6 +162,7 @@ func init() {
// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (default 0)")
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
@@ -200,6 +203,8 @@ func initConfigFromEnv() {
config.DisableVersionCheck = getEnabledFromEnv("MP_DISABLE_VERSION_CHECK")
config.DisableAutoVACUUM = getEnabledFromEnv("MP_DISABLE_AUTO_VACUUM")
if len(os.Getenv("MP_COMPRESSION")) > 0 {
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
}
@@ -249,6 +254,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")
}
@@ -387,6 +395,9 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
}
if len(os.Getenv("MP_WEBHOOK_DELAY")) > 0 {
webhook.Delay, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_DELAY"))
}
// Demo mode
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")

View File

@@ -46,6 +46,10 @@ var (
// @see https://sqlite.org/wal.html
DisableWAL bool
// DisableAutoVACUUM will disable the auto-VACUUM of the local SQLite database when messages
// are deleted and a preconfigured threshold is reached.
DisableAutoVACUUM bool
// Compression is the compression level used to store raw messages in the database:
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
Compression = 1
@@ -127,6 +131,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)

54
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/PuerkitoBio/goquery v1.12.0
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/ghru/v2 v2.0.2
github.com/axllent/ghru/v2 v2.2.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.5
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.33.0
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/text v0.35.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.48.0
)
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.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.1 // 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/fatih/color v1.19.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
@@ -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-0.20260115111900-9e59c2286df0 // indirect
github.com/olekukonko/tablewriter v1.1.3 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.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.38.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

131
go.sum
View File

@@ -1,13 +1,13 @@
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/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.2.0 h1:DzWyWPJL+3qSwvR2S4tTetOhVgP9XjJixng1Aax8GGo=
github.com/axllent/ghru/v2 v2.2.0/go.mod h1:tyH60pqmLCDHd3UMOZyiedrYMFVLwBQqPQ5y8WLvDzA=
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -16,29 +16,28 @@ 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.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
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.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
github.com/clipperhouse/uax29/v2 v2.3.1/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=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.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,10 +92,10 @@ 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-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
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/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -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.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic=
github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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=
@@ -195,8 +186,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
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.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
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

@@ -2,10 +2,39 @@
# This script will install the latest release of Mailpit.
show_help() {
cat <<EOF
Mailpit install script
Usage:
$(basename "$0") [OPTIONS]
Options:
-h, --help Show this help and exit
--install-path <path> Install location (default: /usr/local/bin)
--auth, --auth-token,
--github-token, --token <token> GitHub token for API authentication
Environment:
INSTALL_PATH Default install path override
GITHUB_TOKEN GitHub API token
EOF
}
# Show help if requested
for arg in "$@"; do
case "$arg" in
-h|--help)
show_help
exit 0
;;
esac
done
# Check dependencies is installed
for cmd in curl tar; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Then $cmd command is required but not installed."
echo "The $cmd command is required but not installed."
echo "Please install $cmd and try again."
exit 1
fi
@@ -17,7 +46,7 @@ case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*)
echo "OS not supported."
echo "Unsupported operating system: $(uname -s)"
exit 2
;;
esac
@@ -35,7 +64,7 @@ aarch64 | arm64)
OS_ARCH="arm64"
;;
*)
echo "OS architecture not supported."
echo "Unsupported architecture: $(uname -m)"
exit 2
;;
esac
@@ -47,7 +76,7 @@ TIMEOUT=90
# Try the GITHUB_TOKEN environment variable is set globally.
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
# Update the default values if the user has set.
# Override defaults with any user-supplied arguments.
while [ $# -gt 0 ]; do
case $1 in
--install-path)
@@ -66,6 +95,10 @@ while [ $# -gt 0 ]; do
gh*)
GITHUB_API_TOKEN="$1"
;;
*)
echo "ERROR: Invalid GitHub token. Token must start with \"gh\"."
exit 1
;;
esac
;;
*) ;;
@@ -106,12 +139,19 @@ fi
case "$VERSION" in
v[0-9][0-9\.]*) ;;
*)
echo "There was an error trying to check what is the latest version of Mailpit."
echo "Unable to determine the latest version of Mailpit."
echo "Please try again later."
if [ -z "$GITHUB_API_TOKEN" ]; then
echo "Tip: Set GITHUB_TOKEN to authenticate and avoid GitHub API rate limiting."
fi
exit $EXIT_CODE
;;
esac
TEMP_DIR=""
cleanup() { [ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"; }
trap cleanup EXIT
TEMP_DIR="$(mktemp -qd)"
EXIT_CODE=$?
# Ensure the temporary directory exists and is a directory.
@@ -198,17 +238,15 @@ if [ $EXIT_CODE -eq 0 ]; then
fi
fi
else
echo "ERROR: Changing to temporary directory."
echo "ERROR: Could not change to temporary directory."
exit $EXIT_CODE
fi
# Cleanup the temporary directory.
rm -rf "$TEMP_DIR"
# Check the EXIT_CODE variable, and print the success or error message.
if [ $EXIT_CODE -ne 0 ]; then
echo "There was an error installing Mailpit."
exit $EXIT_CODE
fi
echo "Installed successfully to \"$INSTALL_BIN_PATH\"."
echo "Mailpit ${VERSION} installed successfully to \"$INSTALL_BIN_PATH\"."
exit 0

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

@@ -16,6 +16,14 @@ import (
// Database cron runs every minute
func dbCron() {
if config.DisableAutoVACUUM {
if sqlDriver == "rqlite" {
logger.Log().Warn("[db] disable-auto-vacuum has no effect as rqlite handles vacuuming automatically")
} else {
logger.Log().Infof("[db] auto-VACUUM is disabled")
}
}
for {
time.Sleep(60 * time.Second)
@@ -35,7 +43,7 @@ func dbCron() {
deletedPercent = float64(deletedSize * 100 / total)
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
if !config.DisableAutoVACUUM && deletedPercent >= 1 {
logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
vacuumDb()
}
@@ -128,7 +136,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 +162,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

@@ -3,6 +3,9 @@ package storage
import (
"bytes"
"context"
"crypto/md5" // #nosec
"crypto/sha1" // #nosec
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
@@ -505,6 +508,14 @@ func AttachmentSummary(a *enmime.Part) Attachment {
o.ContentID = a.ContentID
o.Size = uint64(len(a.Content))
md5Hash := md5.Sum(a.Content) // #nosec
sha1Hash := sha1.Sum(a.Content) // #nosec
sha256Hash := sha256.Sum256(a.Content)
o.Checksums.MD5 = hex.EncodeToString(md5Hash[:])
o.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:])
o.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:])
return o
}
@@ -645,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
}
@@ -685,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()
@@ -263,6 +263,11 @@ func TestRegularAttachmentHandling(t *testing.T) {
if msg.Attachments[0].ContentID != "" {
t.Errorf("Test case 3: Expected empty ContentID, got '%s'", msg.Attachments[0].ContentID)
}
// Checksum tests
assertEqual(t, msg.Attachments[0].Checksums.MD5, "b04930eb1ba0c62066adfa87e5d262c4", "Attachment MD5 checksum does not match")
assertEqual(t, msg.Attachments[0].Checksums.SHA1, "15605d6a2fca44e966209d1701f16ecf816df880", "Attachment SHA1 checksum does not match")
assertEqual(t, msg.Attachments[0].Checksums.SHA256, "92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec", "Attachment SHA256 checksum does not match")
}
func TestMixedAttachmentHandling(t *testing.T) {

View File

@@ -27,7 +27,7 @@ func ReindexAll() {
err := sqlf.Select("ID").To(&i).
From(tenant("mailbox")).
OrderBy("Created DESC").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
ids = append(ids, i)
})

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

@@ -15,7 +15,7 @@ func SettingGet(k string) string {
Select("Value").To(&result).
Where("Key = ?", k).
Limit(1).
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return ""
@@ -41,7 +41,7 @@ func getDeletedSize() uint64 {
Select("Value").To(&result).
Where("Key = ?", "DeletedSize").
Limit(1).
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0
@@ -55,7 +55,7 @@ func totalMessagesSize() uint64 {
var result sql.NullFloat64
err := sqlf.From(tenant("mailbox")).
Select("SUM(Size)").To(&result).
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0

View File

@@ -48,7 +48,7 @@ type Message struct {
Attachments []Attachment
}
// Attachment struct for inline and attachments
// Attachment struct for inline images and attachments
//
// swagger:model Attachment
type Attachment struct {
@@ -62,6 +62,15 @@ type Attachment struct {
ContentID string
// Size in bytes
Size uint64
// File checksums
Checksums struct {
// MD5 checksum hash of file
MD5 string
// SHA1 checksum hash of file
SHA1 string
// SHA256 checksum hash of file
SHA256 string
}
}
// MessageSummary struct for frontend messages

View File

@@ -147,7 +147,7 @@ func GetAllTags() []string {
Select(`DISTINCT Name`).
From(tenant("tags")).To(&name).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -169,7 +169,7 @@ func GetAllTagsCount() map[string]int64 {
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy(tenant("message_tags.TagID")).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags[name] = int64(total)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -352,7 +352,7 @@ func getMessageTags(id string) []string {
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
Where(tenant("message_tags.ID")+` = ?`, id).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())

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() != ""
}

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

135
server/cors.go Normal file
View File

@@ -0,0 +1,135 @@
package server
import (
"net/http"
"net/url"
"sort"
"strings"
"github.com/axllent/mailpit/internal/logger"
)
var (
// AccessControlAllowOrigin CORS policy - set with flags/env
AccessControlAllowOrigin string
// CorsAllowOrigins are optional allowed origins by hostname, set via setCORSOrigins().
corsAllowOrigins = make(map[string]bool)
)
// equalASCIIFold reports whether s and t, interpreted as UTF-8 strings, are equal
// under Unicode case folding, ignoring any difference in length.
func asciiFoldString(s string) string {
b := make([]byte, len(s))
for i := range s {
b[i] = toLowerASCIIFold(s[i])
}
return string(b)
}
// toLowerASCIIFold returns the Unicode case-folded equivalent of the ASCII character c.
// It is equivalent to the Unicode 13.0.0 function foldCase(c, CaseFoldingMapping).
func toLowerASCIIFold(c byte) byte {
if 'A' <= c && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}
// CorsOriginAccessControl checks if the request origin is allowed based on the configured CORS origins.
func corsOriginAccessControl(r *http.Request) bool {
origin := r.Header["Origin"]
if len(origin) != 0 {
u, err := url.Parse(origin[0])
if err != nil {
logger.Log().Errorf("[cors] origin parse error: %v", err)
return false
}
_, allAllowed := corsAllowOrigins["*"]
// allow same origin, or if "*" is defined as an origin
if asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed {
return true
}
// 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
}
return true
}
// SetCORSOrigins sets the allowed CORS origins from a comma-separated string.
// 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)
hosts := extractOrigins(AccessControlAllowOrigin)
for _, host := range hosts {
corsAllowOrigins[asciiFoldString(host)] = true
}
if _, wildCard := corsAllowOrigins["*"]; wildCard {
// reset to just wildcard
corsAllowOrigins = make(map[string]bool)
corsAllowOrigins["*"] = true
logger.Log().Info("[cors] all origins are allowed due to wildcard \"*\"")
} else {
keys := make([]string, 0)
for k := range corsAllowOrigins {
keys = append(keys, k)
}
sort.Strings(keys)
logger.Log().Infof("[cors] allowed API origins: %v", strings.Join(keys, ", "))
}
}
// extractOrigins extracts and returns a sorted list of origins from a comma-separated string.
func extractOrigins(str string) []string {
origins := make([]string, 0)
s := strings.TrimSpace(str)
if s == "" {
return origins
}
hosts := strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ' '
})
for _, host := range hosts {
h := strings.TrimSpace(host)
if h != "" {
if h == "*" {
return []string{"*"}
}
if !strings.HasPrefix(h, "http://") && !strings.HasPrefix(h, "https://") {
h = "http://" + h
}
u, err := url.Parse(h)
if err != nil || u.Hostname() == "" || strings.Contains(h, "*") {
logger.Log().Warnf("[cors] invalid CORS origin \"%s\", ignoring", h)
continue
}
// Store host:port so port differences are respected.
// u.Host equals u.Hostname() when no port is present.
origins = append(origins, u.Host)
}
}
sort.Strings(origins)
return origins
}

122
server/cors_test.go Normal file
View File

@@ -0,0 +1,122 @@
package server
import (
"net/http"
"testing"
)
func TestExtractOrigins(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "empty string",
input: "",
expected: []string{},
},
{
name: "single hostname",
input: "example.com",
expected: []string{"example.com"},
},
{
name: "multiple hostnames comma separated",
input: "example.com,foo.com",
expected: []string{"example.com", "foo.com"},
},
{
name: "multiple hostnames space separated",
input: "example.com foo.com",
expected: []string{"example.com", "foo.com"},
},
{
name: "wildcard",
input: "*",
expected: []string{"*"},
},
{
name: "mixed protocols",
input: "http://example.com,https://foo.com:8080",
expected: []string{"example.com", "foo.com:8080"},
},
{
name: "embedded wildcard",
input: "http://example.com,*,https://test",
expected: []string{"*"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractOrigins(tt.input)
if len(got) != len(tt.expected) {
t.Errorf("expected %d origins, got %d", len(tt.expected), len(got))
return
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("expected origin %q, got %q", tt.expected[i], got[i])
}
}
})
}
}
func TestCorsOriginAccessControl(t *testing.T) {
// Setup allowed origins
AccessControlAllowOrigin = "example.com,foo.com,bar.com"
setCORSOrigins()
tests := []struct {
name string
origin string
host string
allow bool
}{
{"no origin header", "", "example.com", 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},
{"ascii fold: allowed by hostname uppercase", "HTTP://FOO.COM", "mailpit.local", true},
{"ascii fold: host uppercase", "http://example.com", "MAILPIT.LOCAL", true},
{"ascii fold: not allowed origin uppercase", "HTTP://NOTALLOWED.COM", "mailpit.local", false},
{"ascii fold: mixed case", "HtTp://ExAmPlE.CoM", "mailpit.local", true},
{"non-ascii: allowed origin (unicode hostname)", "http://exámple.com", "mailpit.local", false},
{"non-ascii: allowed by hostname (unicode)", "http://föö.com", "mailpit.local", false},
{"non-ascii: host uppercase (unicode)", "http://exámple.com", "MAILPIT.LOCAL", false},
{"non-ascii: mixed case (unicode)", "HtTp://ExÁmPlE.CoM", "mailpit.local", false},
}
// Add wildcard test
AccessControlAllowOrigin = "*"
setCORSOrigins()
reqWildcard := &http.Request{Header: http.Header{"Origin": {"http://any.com"}}, Host: "mailpit.local"}
if !corsOriginAccessControl(reqWildcard) {
t.Error("Wildcard origin should be allowed")
}
// Reset to specific hosts
AccessControlAllowOrigin = "example.com,foo.com,bar.com"
setCORSOrigins()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{Header: http.Header{}, Host: tt.host}
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
allowed := corsOriginAccessControl(req)
if allowed != tt.allow {
t.Errorf("expected allowed=%v, got %v for origin=%q host=%q", tt.allow, allowed, tt.origin, tt.host)
}
})
}
}

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"
@@ -32,21 +33,30 @@ import (
)
var (
// AccessControlAllowOrigin CORS policy
AccessControlAllowOrigin string
// htmlPreviewRouteRe is a regexp to match the HTML preview route
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()
isReady := &atomic.Value{}
isReady.Store(false)
stats.Track()
websockets.MessageHub = websockets.NewHub()
// set allowed websocket origins from configuration
// websockets.SetAllowedOrigins(AccessControlAllowWSOrigins)
go websockets.MessageHub.Run()
go pop3.Run()
@@ -196,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")
@@ -208,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()
@@ -240,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)
}
}
@@ -287,17 +293,23 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
htmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\.html$`)
}
if AccessControlAllowOrigin != "" &&
(strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI)) {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
if strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI) {
if allowed := corsOriginAccessControl(r); !allowed {
http.Error(w, "Blocked due to CORS violation", http.StatusForbidden)
return
}
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
// 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 {
@@ -311,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
}
@@ -329,7 +345,8 @@ 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) {
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",
},
@@ -130,14 +130,14 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
// read first 10 IDs
t.Log("Get first 10 IDs")
putIDS := []string{}
putIDs := []string{}
for idx, msg := range m.Messages {
if idx == 10 {
break
}
// store for later
putIDS = append(putIDS, msg.ID)
putIDs = append(putIDs, msg.ID)
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
@@ -145,7 +145,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
t.Log("Mark first 10 as read")
putData := putDataStruct
putData.Read = true
putData.IDs = putIDS
putData.IDs = putIDs
j, err := json.Marshal(putData)
if err != nil {
t.Error(err.Error())
@@ -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

@@ -1,5 +1,6 @@
<script>
import commonMixins from "../../mixins/CommonMixins";
import { mailbox } from "../../stores/mailbox";
import ICAL from "ical.js";
import dayjs from "dayjs";
@@ -19,6 +20,7 @@ export default {
data() {
return {
mailbox,
ical: false,
};
},
@@ -74,46 +76,125 @@ export default {
</script>
<template>
<div class="mt-4 border-top pt-4">
<a
<hr />
<button
class="btn btn-sm btn-outline-secondary mb-3"
@click="mailbox.showAttachmentDetails = !mailbox.showAttachmentDetails"
>
<i class="bi me-1" :class="mailbox.showAttachmentDetails ? 'bi-eye-slash' : 'bi-eye'"></i>
{{ mailbox.showAttachmentDetails ? "Hide" : "Show" }} attachment details
</button>
<div class="row gx-1 w-100">
<div
v-for="part in attachments"
:key="part.PartID"
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3"
target="_blank"
style="width: 180px"
@click="openAttachment(part, $event)"
:class="mailbox.showAttachmentDetails ? 'col-12' : 'col-auto'"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top"
alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i>
<div class="row gx-1 mb-3">
<div class="col-auto">
<a
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3"
target="_blank"
style="width: 180px"
@click="openAttachment(part, $event)"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top"
alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
<p class="mb-1">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div>
</a>
</div>
<div v-if="mailbox.showAttachmentDetails" class="col">
<h5 class="mb-1">
<a
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="me-2"
@click="openAttachment(part, $event)"
>
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</a>
<small class="text-muted fw-light">
<small>({{ getFileSize(part.Size) }})</small>
</small>
</h5>
<p class="mb-1 small"><strong>Disposition</strong>: {{ part.ContentDisposition }}</p>
<p class="mb-2 small">
<strong>Content type</strong>: <code>{{ part.ContentType }}</code>
</p>
<p class="m-0 small">
<strong>MD5</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-sm btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.MD5, $event)"
>
{{ part.Checksums.MD5 }}
<i v-if="!copiedText[part.Checksums.MD5]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.MD5 }}</code>
</p>
<p class="m-0 small">
<strong>SHA1</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.SHA1, $event)"
>
{{ part.Checksums.SHA1 }}
<i v-if="!copiedText[part.Checksums.SHA1]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.SHA1 }}</code>
</p>
<p class="m-0 small">
<strong>SHA256</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-sm btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.SHA256, $event)"
>
{{ part.Checksums.SHA256 }}
<i v-if="!copiedText[part.Checksums.SHA256]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.SHA256 }}</code>
</p>
</div>
</div>
<div class="card-body border-0">
<p class="mb-1">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div>
</a>
</div>
</div>
<!-- ICS Modal -->
<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content">

View File

@@ -14,27 +14,89 @@ export default {
data() {
return {
headers: false,
filter: "",
};
},
computed: {
filteredHeaders() {
if (this.filter === "") {
return this.headers;
}
const searchWords = this.filter
.toLowerCase()
.split(/\s+/)
.filter((x) => x.length > 0);
const filtered = {};
for (const k in this.headers) {
const values = this.headers[k];
const kLower = k.toLowerCase();
if (searchWords.every((w) => kLower.includes(w))) {
filtered[k] = values;
} else {
const matchingValues = values.filter((v) => {
const vLower = v.toLowerCase();
return searchWords.every((w) => vLower.includes(w));
});
if (matchingValues.length > 0) {
filtered[k] = matchingValues;
}
}
}
return filtered;
},
},
mounted() {
const uri = this.resolve("/api/v1/message/" + this.message.ID + "/headers");
this.get(uri, false, (response) => {
this.headers = response.data;
});
},
methods: {
highlight(text) {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (!this.filter || this.filter.trim() === "") {
return escaped;
}
const words = this.filter
.trim()
.split(/\s+/)
.filter((w) => w.length > 0)
.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(words.join("|"), "gi");
return escaped.replace(regex, "<mark>$&</mark>");
},
},
};
</script>
<template>
<div v-if="headers" class="small">
<div v-for="(values, k) in headers" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
<template v-if="headers">
<div class="row w-100 mb-3">
<div class="col col-md-10 col-lg-7">
<input
v-model.trim="filter"
type="search"
class="form-control mb-3"
placeholder="Filter headers..."
aria-label="Filter headers"
/>
</div>
</div>
<div v-if="Object.keys(filteredHeaders).length > 0" class="small">
<div v-for="(values, k) in filteredHeaders" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
<b>{{ k }}</b>
<!-- eslint-disable-next-line vue/no-v-html -->
<b v-html="highlight(k)"></b>
</div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break" v-html="highlight(x)"></div>
</div>
</div>
</div>
<div v-else class="text-body-secondary">No matching headers found.</div>
</template>

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"],
});
},
},
};
@@ -779,6 +785,7 @@ export default {
:srcdoc="sanitizedHTML"
frameborder="0"
style="width: 100%; height: 100%; background: #fff"
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
@load="resizeIframe"
>
</iframe>

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

@@ -20,9 +20,16 @@ export default {
return {
loading: 0,
tagColorCache: {},
copiedText: {}, // used for clipboard copy feedback
};
},
computed: {
copyToClipboardSupported() {
return !!navigator.clipboard;
},
},
methods: {
resolve(u) {
return this.$router.resolve(u).href;
@@ -222,12 +229,15 @@ export default {
allAttachments(message) {
const a = [];
for (const i in message.Attachments) {
message.Attachments[i].ContentDisposition = "Attachment";
a.push(message.Attachments[i]);
}
for (const i in message.OtherParts) {
message.OtherParts[i].ContentDisposition = "Other";
a.push(message.OtherParts[i]);
}
for (const i in message.Inline) {
message.Inline[i].ContentDisposition = "Inline";
a.push(message.Inline[i]);
}
@@ -288,5 +298,21 @@ export default {
return this.tagColorCache[s];
},
// Copy to clipboard functionality
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(
() => {
this.copiedText[text] = true;
setTimeout(() => {
delete this.copiedText[text];
}, 2000);
},
() => {
// failure
alert("Failed to copy to clipboard");
},
);
},
},
};

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"),
@@ -32,6 +45,7 @@ export const mailbox = reactive({
timeZone: localStorage.getItem("timeZone")
? localStorage.getItem("timeZone")
: Intl.DateTimeFormat().resolvedOptions().timeZone,
showAttachmentDetails: localStorage.getItem("showAttachmentDetails"), // show attachment details
});
watch(
@@ -106,3 +120,14 @@ watch(
}
},
);
watch(
() => mailbox.showAttachmentDetails,
(v) => {
if (v) {
localStorage.setItem("showAttachmentDetails", "1");
} else {
localStorage.removeItem("showAttachmentDetails");
}
},
);

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

@@ -1345,9 +1345,27 @@
"x-go-package": "github.com/axllent/mailpit/internal/stats"
},
"Attachment": {
"description": "Attachment struct for inline and attachments",
"description": "Attachment struct for inline images and attachments",
"type": "object",
"properties": {
"Checksums": {
"description": "File checksums",
"type": "object",
"properties": {
"MD5": {
"description": "MD5 checksum hash of file",
"type": "string"
},
"SHA1": {
"description": "SHA1 checksum hash of file",
"type": "string"
},
"SHA256": {
"description": "SHA256 checksum hash of file",
"type": "string"
}
}
},
"ContentID": {
"description": "Content ID",
"type": "string"

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"sync"
"time"
"github.com/axllent/mailpit/config"
@@ -13,12 +14,18 @@ import (
)
var (
// RateLimit is the minimum number of seconds between requests
// RateLimit is the minimum number of seconds between requests.
// Additional requests within this period will be ignored until
// the time has elapsed.
RateLimit = 1
// Delay is the number of seconds to wait before sending each webhook request
// This can allow for other processing to complete before the webhook is triggered.
Delay = 0
rl rate.Sometimes
rateLimiterSet bool
once sync.Once
)
// Send will post the MessageSummary to a webhook (if configured)
@@ -27,18 +34,22 @@ func Send(msg any) {
return
}
if !rateLimiterSet {
once.Do(func() {
if RateLimit > 0 {
rl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second}
} else {
// run 1000 per second - ie: do not limit
rl = rate.Sometimes{First: 1000, Interval: time.Second}
// allow every request
rl = rate.Sometimes{Every: 1}
}
rateLimiterSet = true
}
})
rl.Do(func() {
go func() {
// apply delay if configured
if Delay > 0 {
time.Sleep(time.Duration(Delay) * time.Second)
}
go func() {
rl.Do(func() {
b, err := json.Marshal(msg)
if err != nil {
logger.Log().Errorf("[webhook] invalid data: %s", err.Error())
@@ -58,19 +69,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

@@ -35,6 +35,10 @@ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
EnableCompression: true,
CheckOrigin: func(_ *http.Request) bool {
// origin is checked via server.go's CORS settings
return true
},
}
// Client is a middleman between the websocket connection and the hub.
@@ -94,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)
}
@@ -143,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
}