Compare commits

..

222 Commits

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

This means traffic from multiple customers exits through a small pool of public IPs - a second layer of NAT on top of whatever NAT the customer's own router does (hence "double NAT").
2026-05-14 15:01:36 +12:00
Ralph Slooten
136bdde953 Security: Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes (GHSA-fpxj-m5q8-fphw)
This is a configurable limit (in MB's) which can optionally be disabled by setting it to 0.
2026-05-12 17:22:00 +12:00
Ralph Slooten
499a543963 Feature: New loading indicator, reduce flash during message transitions (#682) 2026-05-12 15:27:12 +12:00
Ralph Slooten
8b4c9d1267 Update AppAbout.vue: Enhance version notification display for stable and development builds 2026-05-10 10:41:38 +12:00
Ralph Slooten
1cabac31ad Update README.md: Adjust email processing rates and clarify email pruning methods 2026-05-10 10:24:29 +12:00
Ralph Slooten
da7b82378c Build: Tag Docker edge build with next patch versions 2026-05-09 17:55:03 +12:00
Ralph Slooten
0702241fa5 Fix test expectations and handle Strip function return values in html2text tests 2026-05-09 17:26:15 +12:00
Ralph Slooten
8d72191704 Prevent duplicate extraction calls in the extract function 2026-05-09 17:22:51 +12:00
Ralph Slooten
052afdf929 Rename variable for clarity in extract function 2026-05-09 17:22:07 +12:00
Ralph Slooten
c1fbbffded Refactor html2text.Strip to return an error and handle it in storage and tools packages 2026-05-09 17:21:36 +12:00
Ralph Slooten
6e2c42d2bc Improve error handling in autoForwardMessage and ensure proper client closure in createForwardingSMTPClient 2026-05-09 17:16:52 +12:00
Ralph Slooten
da8eb3ece8 Fix: Validate SMTP XCLIENT args before processing 2026-05-09 17:13:22 +12:00
Ralph Slooten
4502cdc358 Handle error in writePump when sending ping messages 2026-05-09 17:06:14 +12:00
Ralph Slooten
fbb63c89dd Chore: Simplify writePump by using WriteMessage and remove unnecessary newline handling 2026-05-09 17:05:22 +12:00
Ralph Slooten
71bd44bbb5 Chore: Ensure websocket connection is closed on client unregistration 2026-05-09 17:02:48 +12:00
Ralph Slooten
b997fff7eb Chore: Refactor Hub to use atomic clientCount for safe concurrent client tracking 2026-05-09 17:01:47 +12:00
Ralph Slooten
034a480a39 Chore: Refactor addMessageTag function to remove mutex and ensure safe concurrent inserts 2026-05-09 16:48:05 +12:00
Ralph Slooten
f575b53854 Chore: Refactor pruneMessages function to eliminate duplicate ID checks using a map 2026-05-09 16:43:40 +12:00
Ralph Slooten
d469aac87c Chore: Optimize MarkRead and MarkUnread functions to reduce database calls and improve performance 2026-05-09 16:40:27 +12:00
Ralph Slooten
e4c3442e39 Chore: Enhance SetMessageTags function to improve tag handling and batch deletions 2026-05-09 16:35:21 +12:00
Ralph Slooten
f11fc1ffe0 Chore: Optimize tag retrieval by batching message IDs in List and Search functions 2026-05-09 16:27:58 +12:00
Ralph Slooten
40c5936f79 Chore: Refactor MarkRead and MarkUnread functions to only broadcast changes of modified messages 2026-05-09 16:13:05 +12:00
Ralph Slooten
8bc966e618 Chore: Refactor Prometheus metrics implementation and remove unused dependencies 2026-05-06 16:28:43 +12:00
Ralph Slooten
ec2a0851ab Build: Update CI actions to use npm ci 2026-05-06 15:41:01 +12:00
Ralph Slooten
4bdbeebcc0 Chore: Bump axios version to v1.16.0 2026-05-06 15:34:49 +12:00
Ralph Slooten
10430f7dce Chore: Improve iframe height adjustment with optional chaining 2026-05-05 17:41:17 +12:00
Ralph Slooten
878c68bb49 Chore: Replace lithammer/shortuuid with custom shortuuid implementation and update tests 2026-05-05 17:09:55 +12:00
Ralph Slooten
86b0cf8557 Chore: Remove go-telnet dependency and implement TCP/Unix socket handling for SMTP 2026-05-05 16:48:33 +12:00
Ralph Slooten
123ec9a354 Chore: Remove logrus dependency and implement slog-based logging 2026-05-05 16:48:33 +12:00
Ralph Slooten
3b2423bdf1 Chore: Remove gorilla/mux dependency and replace with stdlib routing 2026-05-05 16:47:51 +12:00
Ralph Slooten
6baf59cb06 Merge branch 'release/v1.29.7' 2026-04-16 17:56:01 +12:00
Ralph Slooten
f0777c7e63 Release v1.29.7 2026-04-16 17:56:00 +12:00
Ralph Slooten
91a4b81c80 Chore: Update node dependencies 2026-04-16 17:26:57 +12:00
Ralph Slooten
943e3394f9 Chore: Update Go dependencies 2026-04-16 17:24:20 +12:00
Ralph Slooten
e84027d39e Change dependabot frequency to biannually 2026-04-16 17:16:24 +12:00
Ralph Slooten
fe9c34f828 Chore: Bump axios version to 1.15.0 2026-04-13 08:18:52 +12:00
Ralph Slooten
9ba51d0ab2 Chore: Bump vue-router from 4.6.4 to 5.0.4 2026-04-05 22:24:51 +12:00
Ralph Slooten
c4dbdc79b0 Rename task 2026-04-05 22:06:50 +12:00
Ralph Slooten
f7fdbb9df9 Merge tag 'v1.29.6' into develop
Release v1.29.6
2026-04-05 22:00:36 +12:00
Ralph Slooten
78d4503a9e Merge branch 'release/v1.29.6' 2026-04-05 22:00:33 +12:00
Ralph Slooten
f9b723aab5 Release v1.29.6 2026-04-05 22:00:32 +12:00
Ralph Slooten
a9fd3e9a07 Chore: Update node dependencies 2026-04-05 21:57:26 +12:00
Ralph Slooten
780c27df44 Chore: Update Go dependencies 2026-04-05 21:53:26 +12:00
Ralph Slooten
5a2d59718f Fix: version check logic in version command and self updater (#673) 2026-04-05 21:51:49 +12:00
Ralph Slooten
794077a836 Use strings.TrimSuffix instead of strings.TrimRight for schema ID extraction 2026-04-04 12:20:33 +13:00
dependabot[bot]
4c6bf1b845 Chore: Bump docker/build-push-action from 6 to 7 (#665)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:04:51 +13:00
dependabot[bot]
d05cbd76a5 Chore: Bump docker/setup-qemu-action from 3 to 4 (#666)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:04:34 +13:00
dependabot[bot]
061674eff4 Chore: Bump docker/setup-buildx-action from 3 to 4 (#668)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:04:16 +13:00
dependabot[bot]
d5ce8597ca Chore: Bump actions/stale from 10.1.1 to 10.2.0 (#669)
Bumps [actions/stale](https://github.com/actions/stale) from 10.1.1 to 10.2.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10.1.1...v10.2.0)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:04:02 +13:00
dependabot[bot]
13a63209a4 Chore: Bump docker/login-action from 3 to 4 (#670)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:03:44 +13:00
Ralph Slooten
1937971233 Merge tag 'v1.29.5' into develop
Release v1.29.5
2026-03-29 17:47:15 +13:00
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
Ralph Slooten
f33f9bec2d Merge branch 'release/v1.28.4' 2026-01-25 10:07:35 +13:00
Ralph Slooten
ff47ba96b8 Release v1.28.4 2026-01-25 10:07:35 +13:00
Ralph Slooten
b9f36312d7 Fix: Avoid error on image type assertion in thumbnail generation
Use imaging.Clone to ensure the image is always *image.NRGBA, preventing panics when decoding non-NRGBA images (e.g., JPEGs as *image.YCbCr).
2026-01-25 10:05:39 +13:00
Ralph Slooten
291c449591 Chore: Update node dependencies 2026-01-25 10:05:38 +13:00
Ralph Slooten
d7a4a60536 Chore: Update Go dependencies 2026-01-25 10:05:38 +13:00
Ralph Slooten
464ff68c34 Fix: Prevent nested MAIL command during an active SMTP transaction (#623) 2026-01-25 10:05:28 +13:00
Ralph Slooten
9383c5876b Fix: Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 (#621) 2026-01-23 17:27:13 +13:00
Ralph Slooten
a3616e52d9 Chore: Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures (#620)
This goes against the RFC5321 recommendation, however enforcing the recommended limits is clearly causing issues with users, and it appears no investigated SMTP servers enforce the strict limits either.
2026-01-23 16:46:29 +13:00
Ralph Slooten
980e54c21f Merge tag 'v1.28.3' into develop
Release v1.28.3
2026-01-18 21:36:02 +13:00
Ralph Slooten
eac491cd89 Merge branch 'release/v1.28.3' 2026-01-18 21:35:55 +13:00
Ralph Slooten
12076bca72 Release v1.28.3 2026-01-18 21:35:54 +13:00
Ralph Slooten
028ca1d715 Chore: Update node dependencies 2026-01-18 12:24:54 +13:00
Ralph Slooten
7d7ba88e9c Chore: Update Go dependencies 2026-01-18 12:22:46 +13:00
Ralph Slooten
973fc1f975 Merge branch 'feature/GHSA-6jxm-fv7w-rw5j' into develop 2026-01-18 12:00:09 +13:00
Ralph Slooten
1679a0aba5 Security: Prevent Server-Side Request Forgery (SSRF) via HTML Check API ([GHSA-6jxm-fv7w-rw5j](https://github.com/axllent/mailpit/security/advisories/GHSA-6jxm-fv7w-rw5j)) 2026-01-18 11:58:24 +13:00
Ralph Slooten
4a4c149eed Formatting 2026-01-18 11:57:23 +13:00
Ralph Slooten
c01335f0e3 Merge branch 'feature/GHSA-54wq-72mp-cq7c' into develop 2026-01-18 11:53:11 +13:00
Ralph Slooten
181cb0714a Test: Add maximum email length validation tests - RFC5321 (section 4.5.3.1) 2026-01-18 11:51:23 +13:00
Ralph Slooten
00d52d5931 Fix: Validate maximum lengths of email addresses - RFC5321 (section 4.5.3.1) 2026-01-18 11:51:23 +13:00
Ralph Slooten
050da038af Test: Add SMTP tests for address compliancy (RFC 5322) and header injection 2026-01-18 11:51:23 +13:00
Ralph Slooten
36cc06c125 Security: Ensure SMTP TO & FROM addresses are RFC 5322 compliant and prevent header injection ([GHSA-54wq-72mp-cq7c](https://github.com/axllent/mailpit/security/advisories/GHSA-54wq-72mp-cq7c)) 2026-01-18 11:50:33 +13:00
Ralph Slooten
2734efbc66 Test: Update tag tests with length limits and @ character 2026-01-17 11:22:19 +13:00
Ralph Slooten
7cda4a36f1 Chore: Allow @ character in message tags & set max length to 100 characters per tag 2026-01-17 11:12:45 +13:00
Ralph Slooten
45b3676e52 Fix: Auto-tagging using SMTP username using plain auth (#617) 2026-01-16 13:50:15 +13:00
BTC-Tim
d50347d667 Fix: Correctly detect macOS group in install.sh (#619) 2026-01-16 10:12:11 +13:00
Omar Kurt
c035139b54 Chore: Fix formatting and update reporting instructions in SECURITY.md (#614) 2026-01-11 10:24:58 +13:00
Ralph Slooten
3108d82e06 Fix: Correctly render default addresses in release modal after settings change (#594) 2026-01-10 22:19:18 +13:00
Ralph Slooten
648d5863da Merge tag 'v1.28.2' into develop
Release v1.28.2
2026-01-10 16:16:14 +13:00
Ralph Slooten
585ea1dc30 Merge branch 'release/v1.28.2' 2026-01-10 16:16:06 +13:00
Ralph Slooten
c66a06379a Release v1.28.2 2026-01-10 16:16:05 +13:00
Ralph Slooten
c5c9292863 More reliable handling for default release email editing 2026-01-10 15:56:19 +13:00
Ralph Slooten
6f1f4f34c9 Security: Prevent Cross-Site WebSocket Hijacking (CSWSH) allowing unauthenticated access to message data [CVE-2026-22689](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm) 2026-01-10 15:42:14 +13:00
Ralph Slooten
877a9159ce Delay bootstrap-tags init until after render 2026-01-08 16:23:24 +13:00
Ralph Slooten
c4582889ad Update default release address wording 2026-01-08 16:20:00 +13:00
Ralph Slooten
cd1cf695b9 Merge branch 'feature/default-release-address' into develop 2026-01-08 16:04:23 +13:00
Ralph Slooten
392904fd23 Chore: Avoid empty URL query parameter when returning to inbox from message view 2026-01-08 16:03:35 +13:00
Ralph Slooten
f0160c0e29 Feature: Allow default mail addresses to be set when releasing message (#594) 2026-01-08 16:03:35 +13:00
Ralph Slooten
f9024d1f77 Chore: Remove webkit warnings about missing template / render functions 2026-01-08 16:03:34 +13:00
Ralph Slooten
061f159293 Merge tag 'v1.28.1' into develop
Release v1.28.1
2026-01-06 15:38:14 +13:00
Ralph Slooten
e69a0d75c9 Merge branch 'release/v1.28.1' 2026-01-06 15:38:11 +13:00
Ralph Slooten
0847167694 Release v1.28.1 2026-01-06 15:38:11 +13:00
Ralph Slooten
6dd3587ec6 Move security commits to top of list 2026-01-06 15:35:49 +13:00
Ralph Slooten
2d1e38d4fd Chore: Update node dependencies 2026-01-06 15:34:20 +13:00
Ralph Slooten
153174f928 Chore: Update Go dependencies 2026-01-06 15:34:20 +13:00
Ralph Slooten
3b9b470c09 Security: Restrict screenshot proxy to only support asset links contained in messages [CVE-2026-21859](https://github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfr)
This fix prevents unrestricted network probing via the screenshot proxy by limiting requests to images, fonts and CSS links found within a message, and returns a generic HTTP error to the client when unsupported content types are requested, not found, or otherwise disallowed.

See CWE-918 Server-Side Request Forgery (SSRF)
2026-01-06 15:33:50 +13:00
dependabot[bot]
dd99a4bcf0 Chore: Bump esbuild from 0.25.12 to 0.27.2 (#611)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.25.12 to 0.27.2.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.12...v0.27.2)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:16:37 +13:00
dependabot[bot]
5bf2f2796b Chore: Bump actions/setup-node from 5 to 6 (#598)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:13:15 +13:00
dependabot[bot]
a469655f65 Chore: Bump actions/stale from 10.0.0 to 10.1.1 (#604)
Bumps [actions/stale](https://github.com/actions/stale) from 10.0.0 to 10.1.1.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10.0.0...v10.1.1)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:12:43 +13:00
dependabot[bot]
432fedeafa Chore: Bump actions/cache from 4 to 5 (#607)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:12:15 +13:00
dependabot[bot]
37e4ff4139 Chore: Bump actions/checkout from 5 to 6 (#610)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:11:51 +13:00
Ralph Slooten
2808316dd2 Temporarily disable swagger validation due to persistent service issues 2025-12-22 17:12:35 +13:00
Ralph Slooten
43d759b0fc Update close-stale-issues workflow to include close-issue-reason 2025-12-22 16:45:44 +13:00
Ralph Slooten
264222d599 Test: Increase swagger test timeout 2025-12-20 17:07:36 +13:00
Ralph Slooten
5e4bdb78b8 Test: Add inline message tests 2025-12-20 16:45:57 +13:00
Ralph Slooten
fc9572156b Merge tag 'v1.28.0' into develop
Release v1.28.0
2025-11-26 17:44:28 +13:00
Ralph Slooten
d52a0d550f Merge branch 'release/v1.28.0' 2025-11-26 17:44:26 +13:00
Ralph Slooten
fcce621f18 Release v1.28.0 2025-11-26 17:44:26 +13:00
Ralph Slooten
f4cd19aac2 Merge branch 'feature/updates' into develop 2025-11-26 17:33:38 +13:00
Ralph Slooten
46ccf866b2 Chore: Update caniemail test database 2025-11-26 17:32:49 +13:00
Ralph Slooten
266611fda0 Chore: Update node dependencies 2025-11-26 17:32:18 +13:00
Ralph Slooten
fe3920e3c6 Chore: Update Go dependencies 2025-11-26 17:29:03 +13:00
Ralph Slooten
ac02802d62 Merge branch 'feature/relay-smtp-errors' into develop 2025-11-26 16:34:39 +13:00
Ralph Slooten
7d6aab4e01 Refactor imports and improve logging in SMTP relay functionality 2025-11-26 16:30:28 +13:00
Ralph Slooten
36d8525557 Refactor command handlers to ignore unused parameters 2025-11-26 16:30:14 +13:00
Dennis
0f0a5d942f Feature: Optionally propagate SMTP errors (#588)
* forward smtp errors

* lint and formatting

* forward smtp errors in forward-impl
2025-11-26 16:17:44 +13:00
dependabot[bot]
b987006897 Bump golang.org/x/crypto from 0.43.0 to 0.45.0 (#586)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 16:14:38 +13:00
dependabot[bot]
c8e0bee8bb Bump js-yaml from 4.1.0 to 4.1.1 (#585)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 08:08:55 +13:00
Ralph Slooten
3b0ae24c2a Merge tag 'v1.27.11' into develop
Release v1.27.11
2025-11-09 11:38:25 +13:00
Ralph Slooten
aca491f10c Merge branch 'release/v1.27.11' 2025-11-09 11:38:22 +13:00
Ralph Slooten
6724f0ccdd Release v1.27.11 2025-11-09 11:38:21 +13:00
Ralph Slooten
93088f3361 Chore: Add type assertion for value in imaging assignment 2025-11-09 11:33:51 +13:00
Ralph Slooten
e817bf5f7d Chore: Update node dependencies 2025-11-09 11:19:03 +13:00
Ralph Slooten
4d100a9ec3 Chore: Update Go dependencies 2025-11-09 11:16:20 +13:00
Ralph Slooten
958fa6cf1a Add thanks.dev link 2025-11-05 22:04:33 +13:00
Ralph Slooten
27e12474f5 Add link to Mailtrap 2025-11-04 16:57:45 +13:00
Ralph Slooten
302b269fb6 Merge tag 'v1.27.10' into develop
Release v1.27.10
2025-10-09 15:40:09 +13:00
108 changed files with 5283 additions and 2347 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
# These are supported funding model platforms
github: [axllent]
thanks_dev: u/gh/axllent

10
.github/SECURITY.md vendored
View File

@@ -2,10 +2,12 @@
Your efforts to responsibly disclose your findings are appreciated.
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
**Please do not report security vulnerabilities through public GitHub issues.**
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
If you believe you have found a security vulnerability, you can report it using one of the following methods:
1. **GitHub Security Advisory (Recommended):** Use the "Report a vulnerability" button in the [Security tab](../../security/advisories/new) of this repository.
2. **Email:** Send your findings to security@axllent.org
Your report should include:
@@ -14,6 +16,6 @@ Your report should include:
- Reproduction steps (if applicable)
- Any other details you think are likely to be important
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
You should receive an initial acknowledgement within 24 hours in most cases, and will be kept updated throughout the process.
With your consent, your contributions will be publicly acknowledged.

1
.github/cliff.toml vendored
View File

@@ -28,6 +28,7 @@ trim = true
# HTML comments added for grouping order, stripped on generation
commit_parsers = [
{body = ".*security", group = "<!-- 1 -->Security"},
{message = "(?i)^security", group = "<!-- 1 -->Security"},
{message = "(?i)^feat", group = "<!-- 2 -->Feature"},
{message = "(?i)^chore", group = "<!-- 3 -->Chore"},
{message = "(?i)^libs", group = "<!-- 3 -->Chore"},

View File

@@ -8,16 +8,16 @@ updates:
- package-ecosystem: "gomod"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
interval: "biannually"
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
interval: "biannually"
- package-ecosystem: "docker"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
interval: "biannually"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "quarterly"
interval: "biannually"

View File

@@ -8,37 +8,53 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
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
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log into Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
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: Calculate next patch version
id: next-version
run: |
TAG="${{ steps.previous-tag.outputs.tag }}"
VERSION="${TAG#v}"
BASE="${VERSION%.*}"
PATCH="${VERSION##*.}"
echo "version=v${BASE}.$((PATCH + 1))-${{ steps.short-sha.outputs.sha }}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
"VERSION=${{ steps.next-version.outputs.version }}"
push: true
tags: |
axllent/mailpit:edge

View File

@@ -8,22 +8,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log into Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -37,7 +37,7 @@ jobs:
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64

View File

@@ -21,15 +21,15 @@ jobs:
- goarch: arm
goos: windows
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
# build the assets
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- run: echo "Building assets for ${{ github.ref_name }}"
- run: npm install
- run: npm ci
- run: npm run package
# build the binaries

View File

@@ -10,12 +10,13 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10.0.0
- uses: actions/stale@v10.2.0
with:
days-before-issue-stale: 7
days-before-issue-close: 3
exempt-issue-labels: "enhancement,bug,awaiting feedback"
stale-issue-label: "stale"
close-issue-reason: "completed"
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
days-before-pr-stale: -1

View File

@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -17,7 +17,7 @@ jobs:
# the HTTP address the rqlite node should advertise
HTTP_ADV_ADDR: "localhost:4001"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:

View File

@@ -16,9 +16,9 @@ jobs:
with:
go-version: ${{ matrix.go-version }}
cache: false
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Go environment
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cache/go-build
@@ -31,20 +31,20 @@ jobs:
# https://olegk.dev/github-actions-and-go
run: gofmt -s -w . && git diff --exit-code
- name: Run Go tests
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v
- name: Run Go benchmarking
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets
- name: Set up node environment
if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install JavaScript dependencies
if: startsWith(matrix.os, 'ubuntu') == true
run: npm install
run: npm ci
- name: Run JavaScript linting
if: startsWith(matrix.os, 'ubuntu') == true
run: npm run lint
@@ -52,9 +52,10 @@ jobs:
if: startsWith(matrix.os, 'ubuntu') == true
run: npm run package
# validate the swagger file
- name: Validate OpenAPI definition
if: startsWith(matrix.os, 'ubuntu') == true
uses: swaggerexpert/swagger-editor-validate@v1
with:
definition-file: server/ui/api/v1/swagger.json
# # validate the swagger file
# - name: Validate OpenAPI definition
# if: startsWith(matrix.os, 'ubuntu') == true
# uses: swaggerexpert/swagger-editor-validate@v1
# with:
# definition-file: server/ui/api/v1/swagger.json
# default-timeout: 20000

View File

@@ -2,6 +2,259 @@
Notable changes to Mailpit will be documented in this file.
## [v1.30.0]
### Security
- Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes (GHSA-fpxj-m5q8-fphw)
- Include CGNAT (Carrier-Grade NAT) in internal IP checks (GHSA-j3fj-qppj-fmmc)
- Block internal IP access by default in HTML check (GHSA-j3fj-qppj-fmmc)
- Fix for path traversal & arbitrary file write in mailpit dump --http via attacker-controlled message IDs (GHSA-qx5x-85p8-vg4j)
- Fix concurrent map read & write in proxy CSS rewriter (GHSA-w4vj-r5pg-3722)
### Feature
- New loading indicator, reduce flash during message transitions ([#682](https://github.com/axllent/mailpit/issues/682))
### Chore
- Bump vue-router from 4.6.4 to 5.0.4
- Bump axios version to 1.15.0
- Update Go dependencies
- Update node dependencies
- Remove gorilla/mux dependency and replace with stdlib routing
- Remove logrus dependency and implement slog-based logging
- Remove go-telnet dependency and implement TCP/Unix socket handling for SMTP
- Replace lithammer/shortuuid with custom shortuuid implementation and update tests
- Improve iframe height adjustment with optional chaining
- Bump axios version to v1.16.0
- Refactor Prometheus metrics implementation and remove unused dependencies
- Refactor MarkRead and MarkUnread functions to only broadcast changes of modified messages
- Optimize tag retrieval by batching message IDs in List and Search functions
- Enhance SetMessageTags function to improve tag handling and batch deletions
- Optimize MarkRead and MarkUnread functions to reduce database calls and improve performance
- Refactor pruneMessages function to eliminate duplicate ID checks using a map
- Refactor addMessageTag function to remove mutex and ensure safe concurrent inserts
- Refactor Hub to use atomic clientCount for safe concurrent client tracking
- Ensure websocket connection is closed on client unregistration
- Simplify writePump by using WriteMessage and remove unnecessary newline handling
- Add message dump max-message-size flag and refactor message handling
- Add message ingest max-message-size flag and refactor message handling
- Update Go dependencies
- Update node dependencies
- Update caniemail test database
### Fix
- Validate SMTP XCLIENT args before processing
### Build
- Update CI actions to use `npm ci`
- Tag Docker edge build with next patch versions
## [v1.29.6]
### Chore
- Bump docker/login-action from 3 to 4 ([#670](https://github.com/axllent/mailpit/issues/670))
- Bump actions/stale from 10.1.1 to 10.2.0 ([#669](https://github.com/axllent/mailpit/issues/669))
- Bump docker/setup-buildx-action from 3 to 4 ([#668](https://github.com/axllent/mailpit/issues/668))
- Bump docker/setup-qemu-action from 3 to 4 ([#666](https://github.com/axllent/mailpit/issues/666))
- Bump docker/build-push-action from 6 to 7 ([#665](https://github.com/axllent/mailpit/issues/665))
- Update Go dependencies
- Update node dependencies
### Fix
- Version check logic in version command and self updater ([#673](https://github.com/axllent/mailpit/issues/673))
## [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
- Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620))
- Update Go dependencies
- Update node dependencies
### Fix
- Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 ([#621](https://github.com/axllent/mailpit/issues/621))
- Prevent nested MAIL command during an active SMTP transaction ([#623](https://github.com/axllent/mailpit/issues/623))
- Avoid error on image type assertion in thumbnail generation
## [v1.28.3]
### Security
- Ensure SMTP TO & FROM addresses are RFC 5322 compliant and prevent header injection ([GHSA-54wq-72mp-cq7c](https://github.com/axllent/mailpit/security/advisories/GHSA-54wq-72mp-cq7c))
- Prevent Server-Side Request Forgery (SSRF) via HTML Check API ([GHSA-6jxm-fv7w-rw5j](https://github.com/axllent/mailpit/security/advisories/GHSA-6jxm-fv7w-rw5j))
### Chore
- Fix formatting and update reporting instructions in SECURITY.md ([#614](https://github.com/axllent/mailpit/issues/614))
- Allow `@` character in message tags & set max length to 100 characters per tag
- Update Go dependencies
- Update node dependencies
### Fix
- Correctly render default addresses in release modal after settings change ([#594](https://github.com/axllent/mailpit/issues/594))
- Correctly detect macOS group in install.sh ([#619](https://github.com/axllent/mailpit/issues/619))
- Auto-tagging using SMTP username using plain auth ([#617](https://github.com/axllent/mailpit/issues/617))
- Validate maximum lengths of email addresses - RFC5321 (section 4.5.3.1)
### Test
- Update tag tests with length limits and `@` character
- Add SMTP tests for address compliancy (RFC 5322) and header injection
- Add maximum email length validation tests - RFC5321 (section 4.5.3.1)
## [v1.28.2]
### Security
- Prevent Cross-Site WebSocket Hijacking (CSWSH) allowing unauthenticated access to message data [CVE-2026-22689](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm)
### Feature
- Allow default mail addresses to be set when releasing message ([#594](https://github.com/axllent/mailpit/issues/594))
### Chore
- Remove webkit warnings about missing template / render functions
- Avoid empty URL query parameter when returning to inbox from message view
## [v1.28.1]
### Security
- Restrict screenshot proxy to only support asset links contained in messages [CVE-2026-21859](https://github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfr)
### Chore
- Bump actions/checkout from 5 to 6 ([#610](https://github.com/axllent/mailpit/issues/610))
- Bump actions/cache from 4 to 5 ([#607](https://github.com/axllent/mailpit/issues/607))
- Bump actions/stale from 10.0.0 to 10.1.1 ([#604](https://github.com/axllent/mailpit/issues/604))
- Bump actions/setup-node from 5 to 6 ([#598](https://github.com/axllent/mailpit/issues/598))
- Bump esbuild from 0.25.12 to 0.27.2 ([#611](https://github.com/axllent/mailpit/issues/611))
- Update Go dependencies
- Update node dependencies
### Test
- Add inline message tests
- Increase swagger test timeout
## [v1.28.0]
### Feature
- Optionally propagate SMTP errors ([#588](https://github.com/axllent/mailpit/issues/588))
### Chore
- Update Go dependencies
- Update node dependencies
- Update caniemail test database
## [v1.27.11]
### Chore
- Update Go dependencies
- Update node dependencies
- Add type assertion for value in imaging assignment
## [v1.27.10]
### Security
@@ -81,6 +334,10 @@ Notable changes to Mailpit will be documented in this file.
## [v1.27.2]
### Security
- Prevent integer overflow conversion to uint64
- Add ReadHeaderTimeout to Prometheus metrics server
### Feature
- Add ability to generate self-signed (snakeoil) certificates for UI, SMTP and POP3 ([#539](https://github.com/axllent/mailpit/issues/539))
@@ -96,10 +353,6 @@ Notable changes to Mailpit will be documented in this file.
- Support angle brackets for text/plain URLs with spaces ([#535](https://github.com/axllent/mailpit/issues/535))
- Do not check latest release for Prometheus statistics ([#522](https://github.com/axllent/mailpit/issues/522))
### Security
- Prevent integer overflow conversion to uint64
- Add ReadHeaderTimeout to Prometheus metrics server
## [v1.27.1]
@@ -1729,6 +1982,9 @@ Notable changes to Mailpit will be documented in this file.
## [1.1.4]
### Security
- Add restrictive HTTP Content-Security-Policy
### Feature
- Add --quiet flag to display only errors
@@ -1737,9 +1993,6 @@ Notable changes to Mailpit will be documented in this file.
- Add favicon unread message counter
- Minor UI color change & unread count position adjustment
### Security
- Add restrictive HTTP Content-Security-Policy
## [1.1.3]
@@ -1826,14 +2079,14 @@ Notable changes to Mailpit will be documented in this file.
## [0.1.2]
### Feature
- Optional browser notifications (HTTPS only)
### Security
- Use strconv.Atoi() for safe string to int conversions
- Sanitize mailbox names
- Don't allow tar files containing a ".."
### Feature
- Optional browser notifications (HTTPS only)
## [0.1.1]

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

@@ -7,7 +7,7 @@ COPY . /app
WORKDIR /app
RUN apk upgrade && apk add git npm && \
npm install && npm run package && \
npm ci && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
FROM alpine:latest

View File

@@ -47,8 +47,8 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 200-300 emails per second over SMTP depending on CPU, network speed & email size,
easily handling tens of thousands of emails, with automatic email pruning by volume or message age (by default keeping the most recent 500 emails)
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
- `List-Unsubscribe` syntax validation
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
@@ -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)
```
@@ -115,3 +115,10 @@ Please refer to [the documentation](https://mailpit.axllent.org/docs/install/tes
Mailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port.
A common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP.
Mailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).
---
<p align="center">
For team features, multiple inboxes, and a hosted setup, try
<a href="https://mailtrap.io/?ref=mailpit">Mailtrap</a>, our friendly companion.
</p>

View File

@@ -9,7 +9,7 @@ import (
// dumpCmd represents the dump command
var dumpCmd = &cobra.Command{
Use: "dump <database> <output-dir>",
Use: "dump <output-dir>",
Short: "Dump all messages from a database to a directory",
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
@@ -17,7 +17,7 @@ The database can either be the database file (eg: --database /var/lib/mailpit/ma
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Run: func(_ *cobra.Command, args []string) {
if err := dump.Sync(args[0]); err != nil {
logger.Log().Fatal(err)
}
@@ -30,7 +30,8 @@ func init() {
dumpCmd.Flags().SortFlags = false
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
dumpCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
}

View File

@@ -7,9 +7,9 @@ import (
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
@@ -30,10 +30,11 @@ Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox
The --recent flag will only consider files with a modification date within the last X days.`,
// Hidden: true,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Run: func(_ *cobra.Command, args []string) {
var count int
var total int
var per100start = time.Now()
limit := int64(config.MaxMessageSize) * 1024 * 1024
for _, a := range args {
err := filepath.Walk(a,
@@ -42,7 +43,7 @@ The --recent flag will only consider files with a modification date within the l
logger.Log().Error(err)
return nil
}
if !isFile(path) {
if !info.Mode().IsRegular() {
return nil
}
@@ -50,6 +51,11 @@ The --recent flag will only consider files with a modification date within the l
return nil
}
if config.MaxMessageSize > 0 && info.Size() > limit {
logger.Log().Warnf("%s exceeds %d MiB size cap, skipping", path, config.MaxMessageSize)
return nil
}
f, err := os.Open(filepath.Clean(path))
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
@@ -57,11 +63,19 @@ The --recent flag will only consider files with a modification date within the l
}
defer func() { _ = f.Close() }()
body, err := io.ReadAll(f)
var reader io.Reader = f
if config.MaxMessageSize > 0 {
reader = io.LimitReader(f, limit+1)
}
body, err := io.ReadAll(reader)
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
if config.MaxMessageSize > 0 && int64(len(body)) > limit {
logger.Log().Warnf("%s exceeds %d MiB size cap, skipping", path, config.MaxMessageSize)
return nil
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
@@ -87,22 +101,33 @@ The --recent flag will only consider files with a modification date within the l
}
}
if sendmail.FromAddr == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
sendmail.FromAddr = fromAddresses[0].Address
}
// Parse the message's From: header once for this iteration.
// Do NOT mutate the package-level sendmail.FromAddr — that
// is the CLI default and would leak across messages.
var msgFrom string
if fromAddresses, err := msg.Header.AddressList("From"); err == nil && len(fromAddresses) > 0 {
msgFrom = fromAddresses[0].Address
}
if len(recipients) == 0 {
// Bcc
recipients = []string{sendmail.FromAddr}
// Bcc — fall back to the message's own From, or the
// CLI-configured default if the message has none.
fallback := msgFrom
if fallback == "" {
fallback = sendmail.FromAddr
}
recipients = []string{fallback}
}
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
// Return-Path per RFC 5321 is "<addr>" (or "<>" for null).
// Use mail.ParseAddress so we only strip the wrapping
// angle brackets, not stray "<"/">" inside the value.
var returnPath string
if rp, err := mail.ParseAddress(msg.Header.Get("Return-Path")); err == nil {
returnPath = rp.Address
}
if returnPath == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
returnPath = fromAddresses[0].Address
}
returnPath = msgFrom
}
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
@@ -134,16 +159,7 @@ func init() {
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
ingestCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
}
// Format a an integer 10000 => 10,000

View File

@@ -28,7 +28,7 @@ status 1 if unhealthy.
If running within Docker, it should automatically detect environment
settings to determine the HTTP bind interface & port.
`,
Run: func(cmd *cobra.Command, args []string) {
Run: func(_ *cobra.Command, _ []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
@@ -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

@@ -18,7 +18,7 @@ var reindexCmd = &cobra.Command{
If you have several thousand messages in your mailbox, then it is advised to shut down
Mailpit while you reindex as this process will likely result in database locking issues.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Run: func(_ *cobra.Command, args []string) {
config.Database = args[0]
config.MaxMessages = 0

View File

@@ -86,11 +86,13 @@ 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")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
rootCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
@@ -103,8 +105,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, HTML checker & screenshots to access internal IP addresses")
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
@@ -160,6 +163,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 +204,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"))
}
@@ -214,6 +220,9 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_MAX_AGE")) > 0 {
config.MaxAge = os.Getenv("MP_MAX_AGE")
}
if len(os.Getenv("MP_MAX_MESSAGE_SIZE")) > 0 {
config.MaxMessageSize, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGE_SIZE"))
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
@@ -249,6 +258,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")
}
@@ -332,6 +344,7 @@ func initConfigFromEnv() {
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
config.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv("MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS")
config.SMTPRelayConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_RELAY_FWD_SMTP_ERRORS")
// SMTP forwarding
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
@@ -350,6 +363,7 @@ func initConfigFromEnv() {
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
config.SMTPForwardConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_FORWARD_FWD_SMTP_ERRORS")
// Chaos
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
@@ -385,6 +399,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

@@ -14,7 +14,7 @@ var versionCmd = &cobra.Command{
Use: "version",
Short: "Display the current version & update information",
Long: `Display the current version & update information (if available).`,
Run: func(cmd *cobra.Command, args []string) {
Run: func(cmd *cobra.Command, _ []string) {
update, _ := cmd.Flags().GetBool("update")
noReleaseCheck, _ := cmd.Flags().GetBool("no-release-check")
@@ -40,8 +40,8 @@ var versionCmd = &cobra.Command{
os.Exit(1)
}
// The latest version is the same version
if release.Tag == config.Version {
// The latest version is not a newer version
if !release.IsNewerThan(config.Version) {
os.Exit(0)
}

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
@@ -121,17 +125,26 @@ var (
// however some servers accept more.
SMTPMaxRecipients = 100
// MaxMessageSize is the maximum size of an inbound message, in megabytes (MiB).
// Applies to both SMTP DATA payloads and the HTTP /api/v1/send body.
// 0 disables the limit (not recommended on network-reachable listeners).
MaxMessageSize = 50
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
// 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
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.@]){1,100}$`)
// TagsConfig is a yaml file to pre-load tags
TagsConfig string
@@ -252,6 +265,7 @@ type SMTPRelayConfigStruct struct {
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
PreserveMessageIDs bool `yaml:"preserve-message-ids"` // preserve the original Message-ID when relaying
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
// DEPRECATED 2024/03/12
RecipientAllowlist string `yaml:"recipient-allowlist"`
@@ -259,18 +273,19 @@ type SMTPRelayConfigStruct struct {
// SMTPForwardConfigStruct struct for parsing yaml & storing variables
type SMTPForwardConfigStruct struct {
To string `yaml:"to"` // comma-separated list of email addresses
Host string `yaml:"host"` // SMTP host
Port int `yaml:"port"` // SMTP port
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
TLS bool `yaml:"tls"` // whether to use TLS
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
To string `yaml:"to"` // comma-separated list of email addresses
Host string `yaml:"host"` // SMTP host
Port int `yaml:"port"` // SMTP port
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
TLS bool `yaml:"tls"` // whether to use TLS
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
}
// VerifyConfig wil do some basic checking
@@ -283,7 +298,8 @@ func VerifyConfig() error {
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
// See server.middleWareFunc()
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
ContentSecurityPolicy = fmt.Sprintf(
"default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
cssFontRestriction, cssFontRestriction,
)
@@ -314,6 +330,10 @@ func VerifyConfig() error {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
if MaxMessageSize == 0 {
logger.Log().Warnf("[smtpd] no message limit set, this is not recommended for network-reachable listeners")
}
// Web UI & API
if UIAuthFile != "" {
UIAuthFile = filepath.Clean(UIAuthFile)
@@ -615,8 +635,10 @@ func VerifyConfig() error {
}
SMTPRelayMatchingRegexp = re
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
logger.Log().Infof(
"[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port,
)
}
}

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)

79
go.mod
View File

@@ -1,77 +1,66 @@
module github.com/axllent/mailpit
go 1.24.3
go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.10.3
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.3
github.com/axllent/semver v1.0.0
github.com/goccy/go-yaml v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/gorilla/mux v1.8.1
github.com/goccy/go-yaml v1.19.2
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime/v2 v2.2.0
github.com/klauspost/compress v1.18.0
github.com/kovidgoyal/imaging v1.6.5
github.com/jhillyerd/enmime/v2 v2.3.0
github.com/klauspost/compress v1.18.6
github.com/kovidgoyal/imaging v1.8.21
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.2.0
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
github.com/prometheus/client_golang v1.23.2
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/pkg/errors v0.9.1
github.com/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/tg123/go-htpasswd v1.2.4
github.com/vanng822/go-premailer v1.25.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
golang.org/x/text v0.30.0
golang.org/x/time v0.14.0
modernc.org/sqlite v1.39.0
github.com/vanng822/go-premailer v1.33.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.50.1
)
require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inbucket/html2text v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // 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.1.0 // indirect
github.com/olekukonko/ll v0.1.1 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // 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-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sys v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.66.10 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

188
go.sum
View File

@@ -1,40 +1,41 @@
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.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
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.3 h1:nLzbq7jLiYQMxYPU4uBdgKL4jzAaMkBfAif3igpGaaE=
github.com/axllent/ghru/v2 v2.2.3/go.mod h1:tyH60pqmLCDHd3UMOZyiedrYMFVLwBQqPQ5y8WLvDzA=
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -44,82 +45,60 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kovidgoyal/imaging v1.6.5 h1:Id9DKlz/ydl5Vxt9QG5IjGSiIcHcszSKXxDubdO49PQ=
github.com/kovidgoyal/imaging v1.6.5/go.mod h1:mBprO214rATK/6OaPAUXmHbSMelPSFEmoBAt/IJdmno=
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.1 h1:9Dfeed5/Mgaxb9lHRAftLK9pVfYETvHn+If6lywVhJc=
github.com/olekukonko/ll v0.1.1/go.mod h1:2dJo+hYZcJMLMbKwHEWvxCUbAOLc/CXWS9noET22Mdo=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9 h1:TS0KUGThBdgr2QURBtaUdNdcRJuwZ1O7/FnhrTDRp0c=
github.com/rqlite/gorqlite v0.0.0-20260504155303-50d445fd0ab9/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -140,32 +119,27 @@ 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.25.0 h1:hGHKfroCXrCDTyGVR8o4HCON5/HWvc7C1uocS+VnaZs=
github.com/vanng822/go-premailer v1.25.0/go.mod h1:8WJKIPZtegxqSOA8+eDFx7QNesKmMYfGEIodLTJqrtM=
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/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -175,8 +149,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -184,23 +158,21 @@ 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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -219,49 +191,47 @@ 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/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.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
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.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -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
@@ -15,9 +44,9 @@ done
OS=
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="Darwin" ;;
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

@@ -7,9 +7,11 @@ import (
"io"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
@@ -18,9 +20,24 @@ import (
"github.com/axllent/mailpit/server/apiv1"
)
// httpClient bounds each remote request so a slow or hostile --http endpoint
// cannot hang the dump indefinitely.
var httpClient = &http.Client{Timeout: time.Minute}
// maxSummarySize caps the bytes read from the remote messages-summary endpoint
// to prevent a hostile server from exhausting memory via an unbounded response.
const maxSummarySize = 20 * 1024 * 1024 // 20 MiB
// pageSize is the per-request limit when paging through the remote messages
// summary endpoint.
const pageSize = 10000
var (
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
// idRe matches a valid Mailpit message ID (alphanumeric or dash, 860 chars).
idRe = regexp.MustCompile(`^[a-zA-Z0-9-]{8,60}$`)
outDir string
// Base URL of mailpit instance
@@ -29,13 +46,15 @@ var (
// URL is the base URL of a remove Mailpit instance
URL string
summary = []storage.MessageSummary{}
dumpIDs = make(map[string]struct {
Timestamp time.Time
})
)
// Sync will sync all messages from the specified database or API to the specified output directory
func Sync(d string) error {
outDir = path.Clean(d)
outDir = filepath.Clean(d)
if URL != "" {
if !linkRe.MatchString(URL) {
@@ -71,53 +90,117 @@ func loadIDs() error {
if base != "" {
// remote
logger.Log().Debugf("Fetching messages summary from %s", base)
res, err := http.Get(base + "api/v1/messages?limit=0")
if err != nil {
return err
start := 0
var total uint64
for {
data, err := fetchSummaryPage(start)
if err != nil {
return err
}
if start == 0 {
total = data.Total
}
for _, m := range data.Messages {
dumpIDs[m.ID] = struct {
Timestamp time.Time
}{Timestamp: m.Created}
}
logger.Log().Debugf("Fetched messages summary page start=%d size=%d (%d/%d)", start, len(data.Messages), len(dumpIDs), total)
// stop on empty page to guard against stale/inconsistent Total
if len(data.Messages) == 0 {
break
}
if uint64(len(dumpIDs)) >= total {
break
}
start += pageSize
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
var data apiv1.MessagesSummary
if err := json.Unmarshal(body, &data); err != nil {
return err
}
summary = data.Messages
} else {
// make sure the database isn't pruned while open
config.MaxMessages = 0
var err error
// local database
if err = storage.InitDB(); err != nil {
if err := storage.InitDB(); err != nil {
return err
}
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
summary, err = storage.List(0, 0, 0)
if err != nil {
return err
start := 0
for {
page, err := storage.List(start, 0, pageSize)
if err != nil {
return err
}
for _, m := range page {
dumpIDs[m.ID] = struct {
Timestamp time.Time
}{Timestamp: m.Created}
}
if len(page) < pageSize {
break
}
start += pageSize
}
}
if len(summary) == 0 {
if len(dumpIDs) == 0 {
return errors.New("no messages found")
}
return nil
}
// fetchSummaryPage fetches a single page of the remote messages summary,
// starting at the given offset.
func fetchSummaryPage(start int) (*apiv1.MessagesSummary, error) {
url := base + "api/v1/messages?limit=" + strconv.Itoa(pageSize) + "&start=" + strconv.Itoa(start)
res, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, errors.New("error fetching messages summary: HTTP " + res.Status)
}
body, err := io.ReadAll(io.LimitReader(res.Body, maxSummarySize+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxSummarySize {
return nil, errors.New("messages summary exceeds size cap")
}
var data apiv1.MessagesSummary
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
return &data, nil
}
func saveMessages() error {
for _, m := range summary {
out := path.Join(outDir, m.ID+".eml")
for id, m := range dumpIDs {
if !idRe.MatchString(id) {
logger.Log().Errorf("skipping message with invalid ID: %q", id)
continue
}
out := filepath.Join(outDir, id+".eml")
// skip if message exists
if tools.IsFile(out) {
@@ -126,37 +209,66 @@ func saveMessages() error {
var b []byte
limit := int64(config.MaxMessageSize) * 1024 * 1024
if base != "" {
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
res, err := httpClient.Get(base + "api/v1/message/" + id + "/raw")
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
continue
}
b, err = io.ReadAll(res.Body)
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
if res.StatusCode != http.StatusOK {
res.Body.Close()
logger.Log().Errorf("error fetching message %s: HTTP %d", id, res.StatusCode)
continue
}
if config.MaxMessageSize > 0 {
b, err = io.ReadAll(io.LimitReader(res.Body, limit+1))
res.Body.Close()
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
continue
}
if int64(len(b)) > limit {
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
continue
}
} else {
b, err = io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
continue
}
}
} else {
var err error
b, err = storage.GetMessageRaw(m.ID)
b, err = storage.GetMessageRaw(id)
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
continue
}
if config.MaxMessageSize > 0 && int64(len(b)) > limit {
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
continue
}
}
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
logger.Log().Errorf("error writing message %s: %s", id, err.Error())
continue
}
_ = os.Chtimes(out, m.Created, m.Created)
_ = os.Chtimes(out, m.Timestamp, m.Timestamp)
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
logger.Log().Debugf("Saved message %s to %s", id, out)
}
return nil

View File

@@ -3,7 +3,7 @@ package html2text
import (
"bytes"
"log"
"fmt"
"regexp"
"strings"
"unicode"
@@ -30,18 +30,18 @@ func init() {
}
// Strip will convert a HTML string to plain text
func Strip(h string, includeLinks bool) string {
func Strip(h string, includeLinks bool) (string, error) {
h = spaceRe.ReplaceAllString(h, "</$1> <")
h = brRe.ReplaceAllString(h, " ")
h = imgRe.ReplaceAllString(h, " <$1")
var buffer bytes.Buffer
doc, err := html.Parse(strings.NewReader(h))
if err != nil {
log.Fatal(err)
return "", fmt.Errorf("html2text: parsing HTML: %w", err)
}
extract(doc, &buffer, includeLinks)
return clean(buffer.String())
return clean(buffer.String()), nil
}
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
@@ -52,7 +52,8 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
if _, skip := skip[c.Data]; !skip {
if _, shouldSkip := skip[c.Data]; !shouldSkip {
extract(c, buff, includeLinks)
if includeLinks && c.Data == "a" {
for _, a := range c.Attr {
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
@@ -60,7 +61,6 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
}
}
}
extract(c, buff, includeLinks)
}
}
}

View File

@@ -20,7 +20,7 @@ func TestPlain(t *testing.T) {
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
for str, expected := range tests {
res := Strip(str, false)
res, _ := Strip(str, false)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()
@@ -42,12 +42,12 @@ func TestWithLinks(t *testing.T) {
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text https://github.com"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text. https://github.com"
for str, expected := range tests {
res := Strip(str, true)
res, _ := Strip(str, true)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2025-09-09 18:08:06 +0000",
"last_update_date":"2026-05-13 14:45:41 +0000",
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
"data":[
{
@@ -75,10 +75,10 @@
"category":"css",
"tags":[],
"keywords":"keyframes",
"last_test_date":"2023-12-19",
"last_test_date":"2026-04-07",
"test_url":"https://www.caniemail.com/tests/css-animation.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/u4oWccYOFNNyTagHs2NSUZqJYQ3MssrqDMocBnRa35hf7/list",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2021-05":"a #1"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #1"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"a #2"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2021-05":"a #1"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"thunderbird":{"macos":{"78.10":"y","149.0.1":"n"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #1"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"a #2"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. Animation properties are supported but `@keyframes` are incorrectly prefixed.","2":"Partial. Only supports from and to keyframes. Does not support % keyframes"}
},
@@ -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`"}
},
@@ -446,7 +446,7 @@
"last_test_date":"2023-12-20",
"test_url":"https://www.caniemail.com/tests/css-border-collapse.html",
"test_results_url":"https://testi.at/proj/4zk4fe7tv86fn4bc6",
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2023-12":"y"},"windows-mail":{"2023-12":"y"},"macos":{"2023-12":"y"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2023-12":"y"},"windows-mail":{"2023-12":"y"},"macos":{"2023-12":"y"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -558,7 +558,7 @@
"last_test_date":"2023-12-20",
"test_url":"https://www.caniemail.com/tests/css-border-spacing.html",
"test_results_url":"https://testi.at/proj/dyodfk8c5dhjanflz",
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"n"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"n"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -638,7 +638,7 @@
"last_test_date":"2024-09-06",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"y"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
},
@@ -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",
@@ -718,7 +718,7 @@
"last_test_date":"2024-04-25",
"test_url":"https://www.caniemail.com/tests/css-comments.html",
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.","2":"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.","3":"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.","4":"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.","5":"Partial. Comment inside `style` attribute works.","6":"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed."}
},
@@ -782,7 +782,7 @@
"last_test_date":"2021-11-02",
"test_url":"https://www.caniemail.com/tests/css-flexbox.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/Kw9bvIPLsmmwVoXhbXpIu1FM31v4nV2KXMaEvPQPezSO9/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2020-11":"a #1"},"android":{"2019-02":"y","2020-11":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2021-11":"n","2024-04":"n"},"android":{"2019-08":"y","2021-11":"n","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"a","2020-11":"y"},"android":{"2019-02":"a","2020-11":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"aol":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2020-11":"a #1","2026-03":"a #1"},"android":{"2019-02":"y","2020-11":"a #1","2026-03":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2021-11":"n","2024-04":"n"},"android":{"2019-08":"y","2021-11":"n","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"a","2020-11":"y"},"android":{"2019-02":"a","2020-11":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"aol":{"desktop-webmail":{"2019-02":"n","2020-11":"y #2"},"ios":{"2019-02":"n","2020-11":"y #2"},"android":{"2019-02":"n","2020-11":"y #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Not supported with non Google accounts.","2":"`display:inline-flex` is not supported."}
},
@@ -798,9 +798,9 @@
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2024-04":"n"},"android":{"2019-08":"y","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"n","2024-01":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"a #1"},"android":{"2019-02":"n","2026-03":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-08":"y","2024-04":"n"},"android":{"2019-08":"y","2024-04":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"n","2024-01":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"aol":{"desktop-webmail":{"2019-02":"n","2026-03":"y"},"ios":{"2019-02":"n","2026-03":"y"},"android":{"2019-02":"n","2026-03":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":null
"notes_by_num":{"1":"Not supported with non Google accounts."}
},
{
@@ -846,7 +846,7 @@
"last_test_date":"2024-08-23",
"test_url":"https://www.caniemail.com/tests/css-empty-cells.html",
"test_results_url":"https://testi.at/proj/kgl7t57xs8jxueze0v8",
"stats":{"apple-mail":{"macos":{"2024-08":"y"},"ios":{"2024-08":"y"}},"gmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"},"mobile-webmail":{"2024-08":"u"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"y"},"outlook-com":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"yahoo":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"n"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-08":"y"},"ios":{"2024-08":"y"}},"gmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"},"mobile-webmail":{"2024-08":"u"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"y"},"outlook-com":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"yahoo":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"n"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -926,11 +926,27 @@
"last_test_date":"2022-08-01",
"test_url":"https://www.caniemail.com/tests/css-font-kerning.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list",
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"128.9.0":"y"}}},
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"128.9.0":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-font-size-adjust",
"title":"font-size-adjust",
"description":"Adjusts the size of lowercase letters relative to the size of uppercase letters.",
"url":"https://www.caniemail.com/features/css-font-size-adjust/",
"category":"css",
"tags":["accessibility"],
"keywords":"font, adjust, fallback, font metric",
"last_test_date":"2026-04-16",
"test_url":"https://www.caniemail.com/tests/css-font-size-adjust.html",
"test_results_url":"https://testi.at/proj/zn7d0pje1e3nsyz2i9",
"stats":{"apple-mail":{"macos":{"11":"n","12":"n","13":"a #1","14":"y","15":"y","26":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"y","18":"y","26":"y"}},"gmail":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"a #2"},"android":{"2026-04":"a #2"},"mobile-webmail":{"2026-04":"y"}},"orange":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"n"},"macos":{"2026-04":"y"},"outlook-com":{"2026-04":"y"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"yahoo":{"desktop-webmail":{"2026-04":"a #3"},"ios":{"2026-04":"a #3"},"android":{"2026-04":"a #3"}},"aol":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"samsung-email":{"android":{"2026-04":"u"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"thunderbird":{"macos":{"149":"y"}},"protonmail":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"y"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"a #1"},"ios":{"2026-04":"y"},"android":{"2026-04":"y"}},"web-de":{"desktop-webmail":{"2026-04":"a #1"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
"notes":"Rendering may depend on browser and OS support, Also applies to mobile client apps.",
"notes_by_num":{"1":"Partial support. Supports one numerical value, 2-value font-metric syntax unsuported (ex-height, cap-height, ch-width, ic-width, ic-height).","2":"Not supported with non Google accounts.","3":"2-value syntax is supported, but the first value (font-metric) is ignored."}
},
{
"slug":"css-font-size",
"title":"font-size",
@@ -942,7 +958,7 @@
"last_test_date":"2024-02-28",
"test_url":"https://www.caniemail.com/tests/css-font-size.html",
"test_results_url":"https://testi.at/proj/vr3ai85bunngsxjjfd2",
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"},"mobile-webmail":{"2024-02":"y"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2024-02":"a #2"},"macos":{"2024-02":"y"},"outlook-com":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"yahoo":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"aol":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"samsung-email":{"android":{"2024-02":"a #2"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"protonmail":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}},"gmx":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"},"mobile-webmail":{"2024-02":"y"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2024-02":"a #2"},"macos":{"2024-02":"y"},"outlook-com":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"yahoo":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"aol":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"samsung-email":{"android":{"2024-02":"a #2"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"protonmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}},"gmx":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial support. `rem` values are not supported.","2":"Partial support. `relative` and `percentage` size values not supported."}
},
@@ -1006,9 +1022,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 +1033,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 +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."}
},
{
@@ -1054,9 +1070,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 +1134,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"}
},
@@ -1171,13 +1187,29 @@
"notes_by_num":{"1":"Partial. Only supported in lowercase. (See [email-bugs#13](https://github.com/hteumeuleu/email-bugs/issues/13))","2":"Partial. Only supported inline when using a Non Gmail Account due to the lack of `<style>` support.","3":"Not supported. The entire declaration is removed if there is no space before `!important`.","4":"Partial. Only supported with a space before.","5":"Partial. Not supported inline. (See [email-bugs#31](https://github.com/hteumeuleu/email-bugs/issues/31))","6":"Partial. Only supported inline due to the lack of `<style>` support.","7":"Partial. Removed if there is no space before when used with a `background-image` property. (See [email-bugs#16](https://github.com/hteumeuleu/email-bugs/issues/16))","8":"Partial. Only supported in lowercase.","9":"Partial. Only supported if not written in lowercase."}
},
{
"slug":"css-inert-attribute",
"title":"inert",
"description":"This attribute should render elements inactive",
"url":"https://www.caniemail.com/features/css-inert-attribute/",
"category":"css",
"tags":["accessibility"],
"keywords":"focus, inactive",
"last_test_date":"2026-04-16",
"test_url":"https://www.caniemail.com/tests/css-inert-attribute.html",
"test_results_url":"https://testi.at/proj/eg6o04ae0kv6t3ek0px",
"stats":{"apple-mail":{"macos":{"2026-04":"y"},"ios":{"2026-04":"y"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"n"},"macos":{"2026-04":"n"},"outlook-com":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"aol":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"}},"samsung-email":{"android":{"2026-04":"y"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-inline-size",
"title":"inline-size ",
"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 +1385,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",
@@ -1502,7 +1534,7 @@
"last_test_date":"2019-08-02",
"test_url":"https://www.caniemail.com/tests/css-width-height.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/dP8XNPcCLZGrogYGvFgCRRjJJO2nTWxchQ0WZSu0Pxcyb/list",
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"5.1":"a #2","6.1":"a #2","10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2003":"a #2","2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-01":"a #1"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"a #2"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"a #2"},"android":{"2019-08":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"a #2"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"5.1":"a #2","6.1":"a #2","10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2003":"a #2","2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-01":"a #1"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"a #2","2025-11":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"a #2","2025-11":"y"},"android":{"2019-08":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"a #2","2025-11":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"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. Only works on `<table>` elements.","2":"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths)."}
},
@@ -1614,9 +1646,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 +1710,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 +1891,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",
@@ -1891,6 +1939,38 @@
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
},
{
"slug":"css-pseudo-class-focus-visible",
"title":":focus-visible",
"description":null,
"url":"https://www.caniemail.com/features/css-pseudo-class-focus-visible/",
"category":"css",
"tags":["accessibility"],
"keywords":"pseudo-class, focus",
"last_test_date":"2026-04-14",
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-class-focus.html",
"test_results_url":"https://testi.at/proj/pv47sy35udb30ae816",
"stats":{"apple-mail":{"macos":{"2026-04":"y #3"},"ios":{"2026-04":"u"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"u"},"macos":{"2026-04":"a #1"},"outlook-com":{"2026-04":"a #1"},"ios":{"2026-04":"u"},"android":{"2026-04":"a #1"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"aol":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"samsung-email":{"android":{"2026-04":"y #2"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Only supported on type selectors.","2":"Input with type text is not focusable at all","3":"Button is not focusable on tab"}
},
{
"slug":"css-pseudo-class-focus-within",
"title":":focus-within",
"description":null,
"url":"https://www.caniemail.com/features/css-pseudo-class-focus-within/",
"category":"css",
"tags":["accessibility"],
"keywords":"pseudo-class, focus",
"last_test_date":"2026-04-14",
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-class-focus.html",
"test_results_url":"https://testi.at/proj/pv47sy35udb30ae816",
"stats":{"apple-mail":{"macos":{"2026-04":"y"},"ios":{"2026-04":"u"}},"gmail":{"desktop-webmail":{"2026-04":"n"},"ios":{"2026-04":"n"},"android":{"2026-04":"n"},"mobile-webmail":{"2026-04":"n"}},"orange":{"desktop-webmail":{"2026-04":"u","2021-03":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2026-04":"u"},"macos":{"2026-04":"a #1"},"outlook-com":{"2026-04":"n"},"ios":{"2026-04":"u"},"android":{"2026-04":"a #1"}},"thunderbird":{"macos":{"2026-04":"u"}},"yahoo":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"aol":{"desktop-webmail":{"2026-04":"y"},"ios":{"2026-04":"u"},"android":{"2026-04":"y"}},"samsung-email":{"android":{"2026-04":"y"}},"sfr":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"protonmail":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"hey":{"desktop-webmail":{"2026-04":"u"}},"mail-ru":{"desktop-webmail":{"2026-04":"u"}},"fastmail":{"desktop-webmail":{"2026-04":"u"}},"laposte":{"desktop-webmail":{"2026-04":"u"}},"gmx":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"web-de":{"desktop-webmail":{"2026-04":"u"},"ios":{"2026-04":"u"},"android":{"2026-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2026-04":"u"},"android":{"2026-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
},
{
"slug":"css-pseudo-class-focus",
"title":":focus",
@@ -2558,7 +2638,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;`"}
},
@@ -2574,7 +2654,7 @@
"last_test_date":"2022-07-20",
"test_url":"https://www.caniemail.com/tests/css-table-layout.html",
"test_results_url":"https://testi.at/proj/G4buV6sBBxUr6quykrtVA3sk",
"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":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"},"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":"n #1"},"outlook-com":{"2022-07":"y","2024-01":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"yahoo":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"aol":{"desktop-webmail":{"2022-07":"y"},"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":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
"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":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"},"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":"n #1"},"outlook-com":{"2022-07":"y","2024-01":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"yahoo":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"aol":{"desktop-webmail":{"2022-07":"y"},"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":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
"notes":null,
"notes_by_num":{"1":"Not supported. All tables are forced to `table-layout:fixed`."}
},
@@ -2585,12 +2665,12 @@
"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",
"test_results_url":"https://app.emailonacid.com/app/acidtest/LxplTmJT9Ilq9GUyn8Aq8MVK6EO427qmx1Ic4A7jc7bOJ/list",
"stats":{"apple-mail":{"macos":{"2022-10":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16.0":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2021-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"n","16.80":"n"},"outlook-com":{"2022-08":"y","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"t-online-de":{"desktop-webmail":{"2022-08":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}}},
"stats":{"apple-mail":{"macos":{"2022-10":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16.0":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2021-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"n","16.80":"n"},"outlook-com":{"2022-08":"y","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"t-online-de":{"desktop-webmail":{"2022-08":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}}},
"notes":null,
"notes_by_num":null
},
@@ -2601,14 +2681,14 @@
"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",
"last_test_date":"2025-11-14",
"test_url":"https://www.caniemail.com/tests/css-text-align.html",
"test_results_url":"https://testi.at/proj/G4YtBn8fBxEsLx6uybqcxD",
"stats":{"apple-mail":{"macos":{"2021-09":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"a #2"},"android":{"2021-09":"a #2"},"mobile-webmail":{"2021-09":"y"}},"orange":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"outlook":{"windows":{"2007":"a #1","2010":"a #1","2013":"a #1","2016":"a #1","2019":"a #1"},"windows-mail":{"2021-09":"a #1"},"macos":{"2021-09":"y","16.80":"y"},"outlook-com":{"2021-09":"y","2024-01":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"yahoo":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"6.37":"a #1"}},"aol":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"sfr":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"thunderbird":{"macos":{"2021-09":"y"}},"protonmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"hey":{"desktop-webmail":{"2021-09":"y"}},"mail-ru":{"desktop-webmail":{"2021-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-09":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y #1"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y #1"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"test_results_url":"https://testi.at/proj/j39ys8ybfbr8srez1ab",
"stats":{"apple-mail":{"macos":{"2021-09":"y","2025-11":"y"},"ios":{"11":"a #3 #5","12":"a #3 #5","13":"a #3 #5","14":"a #3 #5","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2021-09":"y","2025-11":"y #4"},"ios":{"2021-09":"a #2","2025-11":"y"},"android":{"2021-09":"a #2","2025-11":"a #3 #5"},"mobile-webmail":{"2021-09":"y","2025-11":"a #3 #5"}},"orange":{"desktop-webmail":{"2021-09":"a #1"},"ios":{"2021-09":"a #1"},"android":{"2021-09":"a #1"}},"outlook":{"windows":{"2007":"a #1 #3","2010":"a #1 #3","2013":"a #1 #3","2016":"a #1 #3","2019":"a #1 #3"},"windows-mail":{"2021-09":"a #1 #3"},"macos":{"2021-09":"y","16.80":"y","16.103":"y"},"outlook-com":{"2021-09":"y","2024-01":"y","2025-11":"y #4"},"ios":{"2021-09":"y"},"android":{"2021-09":"y","5.2543.1":"a #3"}},"yahoo":{"desktop-webmail":{"2021-09":"a #1","2025-11":"a #1 #3"},"ios":{"2021-09":"a #1","2025-11":"a #1 #3"},"android":{"6.37":"a #1","7.74":"a #1 #3"}},"aol":{"desktop-webmail":{"2021-09":"a #1","2025-11":"a #1 #3"},"ios":{"2021-09":"a #1","2025-11":"a #1 #3"},"android":{"2021-09":"a #1","2025-11":"a #1 #3"}},"samsung-email":{"android":{"6.1.51.1":"a #3 #5","6.2.06.0":"a #3 #5"}},"sfr":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"thunderbird":{"macos":{"2021-09":"y","2025-11":"y"}},"protonmail":{"desktop-webmail":{"2021-09":"y"},"ios":{"2021-09":"y"},"android":{"2021-09":"y"}},"hey":{"desktop-webmail":{"2021-09":"y"}},"mail-ru":{"desktop-webmail":{"2021-09":"y","2025-11":"y #4"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-09":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y #1","2025-11":"a #1 #3"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y #1","2025-11":"a #1 #3"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Flow-relative values `start` and `end` are not supported.","2":"Partial. Flow-relative values `start` and `end` are not supported with non Gmail account."}
"notes_by_num":{"1":"Partial. Flow-relative values `start` and `end` are not supported.","2":"Partial. Flow-relative values `start` and `end` are not supported with non Gmail account.","3":"Partial. Doesn't support the `match-parent` value.","4":"Support of the `match-parent` value depends on browser support.","5":"Supports the vendor prefixed value `-webkit-match-parent`."}
},
{
@@ -2670,7 +2750,7 @@
"last_test_date":"2023-12-06",
"test_url":"https://www.caniemail.com/tests/css-text-decoration-style.html",
"test_results_url":"https://testi.at/proj/jalr04oy0yrxfd7kuo",
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n","2025-06":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n","2025-06":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -2697,7 +2777,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",
@@ -2750,7 +2830,7 @@
"last_test_date":"2021-01-31",
"test_url":"https://www.caniemail.com/tests/css-text-indent.html",
"test_results_url":"https://testi.at/proj/Ew5f99Cy8NuRM0iPMVFoyYI8",
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"2021-01":"a #1"},"android":{"2021-01":"a #1"},"mobile-webmail":{"2021-01":"a #1"}},"orange":{"desktop-webmail":{"2021-01":"y","2021-03":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-01":"y"},"macos":{"2021-01":"y","16.80":"y"},"outlook-com":{"2021-01":"y","2024-01":"y"},"ios":{"2021-01":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.21.1":"a #1"},"android":{"6.16.2.1525679":"a #1"}},"aol":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.0.0":"a #1"},"android":{"5.15.0":"a #1"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"sfr":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"thunderbird":{"macos":{"2021-01":"y"}},"protonmail":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"hey":{"desktop-webmail":{"2021-01":"y"}},"mail-ru":{"desktop-webmail":{"2021-01":"a #2"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"2021-01":"a #1"},"android":{"2021-01":"a #1"},"mobile-webmail":{"2021-01":"a #1"}},"orange":{"desktop-webmail":{"2021-01":"y","2021-03":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-01":"y"},"macos":{"2021-01":"y","16.80":"y"},"outlook-com":{"2021-01":"y","2024-01":"y"},"ios":{"2021-01":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.21.1":"a #1"},"android":{"6.16.2.1525679":"a #1"}},"aol":{"desktop-webmail":{"2021-01":"a #1"},"ios":{"6.0.0":"a #1"},"android":{"5.15.0":"a #1"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"sfr":{"desktop-webmail":{"2021-01":"y"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"thunderbird":{"macos":{"2021-01":"y"}},"protonmail":{"desktop-webmail":{"2021-01":"a #2"},"ios":{"2021-01":"y"},"android":{"2021-01":"y"}},"hey":{"desktop-webmail":{"2021-01":"y"}},"mail-ru":{"desktop-webmail":{"2021-01":"a #2"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported."}
},
@@ -2766,7 +2846,7 @@
"last_test_date":"2024-04-17",
"test_url":"https://www.caniemail.com/tests/css-text-justify.html",
"test_results_url":"https://testi.at/proj/z7b61px4fel2ivk9sb2",
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `text-justify` is stripped","2":"Partial. Depends on browser support","3":"Partial. `text-justify` is stripped except when the value is `inter-character`","4":"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`","5":"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`"}
},
@@ -2825,7 +2905,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",
@@ -2878,7 +2958,7 @@
"last_test_date":"2024-04-03",
"test_url":"https://www.caniemail.com/tests/css-text-wrap.html",
"test_results_url":"https://testi.at/proj/xle5u5a5i9eh9opi7a",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"15":"y","14":"y"}},"gmail":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"},"mobile-webmail":{"2024-04":"n"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-04":"n"},"macos":{"2024-04":"n"},"outlook-com":{"2024-04":"n","2024-01":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"yahoo":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"aol":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"15":"y","14":"y"}},"gmail":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"},"mobile-webmail":{"2024-04":"n"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-04":"n"},"macos":{"2024-04":"n"},"outlook-com":{"2024-04":"n","2024-01":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"yahoo":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"aol":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -3198,9 +3278,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 +3342,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 +3374,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"}
},
@@ -3422,7 +3502,7 @@
"last_test_date":"2024-01-03",
"test_url":"https://www.caniemail.com/tests/html-acronym.html",
"test_results_url":"https://testi.at/proj/ayebhgpxu58yce2bhd",
"stats":{"apple-mail":{"macos":{"2024-01":"y"},"ios":{"2024-01":"y"}},"gmail":{"desktop-webmail":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"},"mobile-webmail":{"2024-01":"y"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n #1","2016":"n #1","2019":"n #1","2021":"n #1"},"windows-mail":{"2024-01":"n #1"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"y"}},"aol":{"desktop-webmail":{"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"}},"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":"n"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"free-fr":{"desktop-webmail":{"2024-01":"u"}},"t-online-de":{"desktop-webmail":{"2024-01":"y"}},"gmx":{"desktop-webmail":{"2024-01":"y"}},"web-de":{"desktop-webmail":{"2024-01":"y"}}},
"stats":{"apple-mail":{"macos":{"2024-01":"y"},"ios":{"2024-01":"y"}},"gmail":{"desktop-webmail":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"},"mobile-webmail":{"2024-01":"y"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n #1","2016":"n #1","2019":"n #1","2021":"n #1"},"windows-mail":{"2024-01":"n #1"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"y"}},"aol":{"desktop-webmail":{"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"}},"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":"n"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"free-fr":{"desktop-webmail":{"2024-01":"u"}},"t-online-de":{"desktop-webmail":{"2024-01":"y"}},"gmx":{"desktop-webmail":{"2024-01":"y"}},"web-de":{"desktop-webmail":{"2024-01":"y"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `title` attribute is removed but keeps `<acronym>` tag."}
},
@@ -3470,7 +3550,7 @@
"last_test_date":"2019-08-08",
"test_url":"https://www.caniemail.com/tests/html-anchor-links.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/lvP3Vdg0qtue1RAuGTjzEXl19nfCJu3TVV4lLdzwdqQk5/list",
"stats":{"apple-mail":{"macos":{"12.4":"y #6"},"ios":{"12.4":"n #3","15.0":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y #7"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"y #6"},"mobile-webmail":{"2020-02":"y #7"}},"orange":{"desktop-webmail":{"2019-08":"a #1","2021-03":"n","2024-04":"n #8"},"ios":{"2019-08":"n #3","2024-04":"n"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"u","2007":"y #7","2010":"y #7","2013":"y #7","2016":"y #7","2019":"y #7"},"windows-mail":{"2020-01":"y #7"},"macos":{"2019":"n","2023":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"n #3"}},"samsung-email":{"android":{"6.0":"n #3"}},"sfr":{"desktop-webmail":{"2019-08":"n #2"},"ios":{"2019-08":"n #4"},"android":{"2019-08":"n #3"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"a #7"},"ios":{"2020-01":"y"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"a #7"},"ios":{"2019-08":"n #5"},"android":{"2019-08":"n #3"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #7 #9"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y #6"},"ios":{"12.4":"n #3","15.0":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y #7"},"ios":{"2019-08":"n #3"},"android":{"2019-08":"y #6"},"mobile-webmail":{"2020-02":"y #7"}},"orange":{"desktop-webmail":{"2019-08":"a #1","2021-03":"n","2024-04":"n #8"},"ios":{"2019-08":"n #3","2024-04":"n"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"u","2007":"y #7","2010":"y #7","2013":"y #7","2016":"y #7","2019":"y #7"},"windows-mail":{"2020-01":"y #7"},"macos":{"2019":"n","2023":"y","16.80":"y"},"outlook-com":{"2019-08":"y","2024-01":"y"},"ios":{"2019-08":"n #3","2026-05":"y"},"android":{"2019-08":"n #3","2026-05":"y"}},"samsung-email":{"android":{"6.0":"n #3"}},"sfr":{"desktop-webmail":{"2019-08":"n #2"},"ios":{"2019-08":"n #4"},"android":{"2019-08":"n #3"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"a #7"},"ios":{"2020-01":"y"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"a #7"},"ios":{"2019-08":"n #5"},"android":{"2019-08":"n #3"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #7 #9"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `target=_blank` is added on links so anchors open in a new window.","2":"Buggy. Anchor links go back to the homepage of the webmail because it also uses anchor links for navigation.","3":"Buggy. Clicking an anchor link does nothing.","4":"Buggy. Opens a new browser window with the anchor as a URL.","5":"Buggy. Opens a new in-app browser window on yahoo.com with the anchor appended to the URL.","6":"Buggy. Targeted content is partially hidden by the application UI on top.","7":"Partial. Works when targeting an empty anchor with the corresponding `name` attribute, but not with `id` attributes.","8":"Not supported. Opens a new window with the same email.","9":"The `name` and `href` attributes are prefixed by a specific `mailruanchor_` prefix."}
},
@@ -3625,7 +3705,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 +3774,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 +3790,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
},
@@ -3742,7 +3822,7 @@
"last_test_date":"2024-05-1",
"test_url":"https://www.caniemail.com/tests/css-comments.html",
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -3769,7 +3849,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 +3945,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 +4169,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 +4233,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 +4281,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 +4425,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 +4457,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",
@@ -4478,9 +4558,9 @@
"last_test_date":"2023-07-27",
"test_url":"https://www.caniemail.com/tests/html-style.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/CAMb612bxbVwRWPhM4wZKNhhdcdkNxj0Rj6dtRRw6LQUO/list",
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y","2025-07":"n"},"ios":{"2019-06":"n","2023-02":"n","2025-07":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3","2025-06":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y","2025-07":"n"},"ios":{"2023-02":"n","2025-07":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y","2025-04":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1 #6"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y","2025-07":"n"},"ios":{"2019-06":"n","2023-02":"n","2025-07":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3","2025-06":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y","2025-07":"n"},"ios":{"2023-02":"n","2025-07":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y","2025-04":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
"notes":"",
"notes_by_num":{"1":"Partial. Not supported inside the `<body>`.","2":"Partial. Not supported with non Google accounts.","3":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","4":"Buggy. `<style>` elements need to be declared before their rules are used.","5":"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)"}
"notes_by_num":{"1":"Partial. Not supported inside the `<body>`.","2":"Partial. Not supported with non Google accounts.","3":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","4":"Buggy. `<style>` elements need to be declared before their rules are used.","5":"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)","6":"The size of the `<style>` tag [is limited to 16 KB](https://github.com/hteumeuleu/email-bugs/issues/90)"}
},
{
@@ -4505,7 +4585,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

@@ -1,8 +1,11 @@
package htmlcheck
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -141,19 +144,20 @@ func inlineRemoteCSS(h string) (string, error) {
attributes := link.Attr
for _, a := range attributes {
if a.Key == "href" {
if !isURL(a.Val) {
// skip invalid URL
continue
}
if config.BlockRemoteCSSAndFonts {
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
return h, nil
}
resp, err := downloadToBytes(a.Val)
if !isValidURL(a.Val) {
// skip invalid URL
logger.Log().Warnf("[html-check] ignoring unsupported stylesheet URL: %s", a.Val)
continue
}
resp, err := downloadCSSToBytes(a.Val)
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
logger.Log().Warnf("[html-check] %s", err.Error())
continue
}
@@ -182,14 +186,20 @@ func inlineRemoteCSS(h string) (string, error) {
return newDoc, nil
}
// DownloadToBytes returns a []byte slice from a URL
func downloadToBytes(url string) ([]byte, error) {
client := http.Client{
Timeout: 5 * time.Second,
// DownloadCSSToBytes returns a []byte slice from a URL.
// It requires the HTTP response code to be 200 and the content-type to be text/css.
// It will download a maximum of 5MB.
func downloadCSSToBytes(url string) ([]byte, error) {
client := safeHTTPClient()
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mailpit HTML Checker/"+config.Version)
// Get the link response data
resp, err := client.Get(url)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
@@ -200,7 +210,17 @@ func downloadToBytes(url string) ([]byte, error) {
return nil, err
}
body, err := io.ReadAll(resp.Body)
ct := strings.ToLower(resp.Header.Get("content-type"))
if !strings.Contains(ct, "text/css") {
err := fmt.Errorf("invalid CSS content-type from %s: \"%s\" (expected \"text/css\")", url, ct)
return nil, err
}
// set a limit on the number of bytes to read - max 5MB
limit := int64(5242880)
limitedReader := &io.LimitedReader{R: resp.Body, N: limit}
body, err := io.ReadAll(limitedReader)
if err != nil {
return nil, err
}
@@ -208,10 +228,12 @@ func downloadToBytes(url string) ([]byte, error) {
return body, nil
}
// Test if str is a URL
func isURL(str string) bool {
// Test if the string is a supported URL.
// The URL must have the "http" or "https" scheme, and must not contain any login info (http://user:pass@<host>).
func isValidURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && u.User.String() == ""
}
// Test the HTML for inline CSS styles and styling attributes
@@ -249,3 +271,64 @@ func testInlineStyles(doc *goquery.Document) map[string]int {
return matches
}
func safeHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}
tr := &http.Transport{
Proxy: nil, // avoid env proxy surprises unless you explicitly want it
DialContext: safeDialContext(dialer),
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 30 * time.Second,
MaxIdleConns: 50,
}
client := &http.Client{
Transport: tr,
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// re-validate every redirect hop.
if len(via) >= 3 {
return errors.New("too many redirects")
}
if !isValidURL(req.URL.String()) {
return errors.New("invalid redirect URL")
}
return nil
},
}
return client
}
// safeDialContext is the same pattern as linkcheck/status.go::safeDialContext
// — copy the function (or factor a shared helper into internal/tools/net.go).
func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if !config.AllowInternalHTTPRequests {
for _, ip := range ips {
if tools.IsInternalIP(ip.IP) {
return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip)
}
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
}

View File

@@ -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
@@ -163,9 +163,9 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
p++
s.Support = "partial"
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
noteIDs := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
for _, id := range noteIDS {
for _, id := range noteIDs {
s.NoteNumber = id
}
}

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

@@ -1,73 +1,177 @@
// Package logger handles the logging
// Mailpit now uses slog for logging, but this package provides a logrus-compatible API and formatting to avoid changing all existing log calls
// and provide backwards compatibility with logrus formatting and features like log levels and file output.
package logger
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"github.com/sirupsen/logrus"
"sync"
)
// Logger wraps slog.Logger providing a logrus-compatible API
type Logger struct {
sl *slog.Logger
}
var (
log *logrus.Logger
log *Logger
// VerboseLogging for verbose logging
VerboseLogging bool
// QuietLogging shows only errors
QuietLogging bool
// NoLogging shows only fatal errors
// NoLogging disables all logging (tests)
NoLogging bool
// LogFile sets a log file
LogFile string
)
// Log returns the logger instance
func Log() *logrus.Logger {
// Log returns the logger instance, initialising it on first call. The level and
// output destination are determined once from VerboseLogging, QuietLogging,
// NoLogging, and LogFile at the time of first use.
func Log() *Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
level := slog.LevelInfo
switch {
case VerboseLogging:
level = slog.LevelDebug
case QuietLogging:
level = slog.LevelError
case NoLogging:
level = slog.Level(100) // above all real levels — silences all output
}
out := os.Stdout
if LogFile != "" {
file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec
if err == nil {
log.Out = file
out = file
} else {
log.Out = os.Stdout
log.Warn("Failed to log to file, using default stderr")
fmt.Fprintln(os.Stderr, "failed to log to file, using default stdout")
}
} else {
log.Out = os.Stdout
}
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
})
log = &Logger{
sl: slog.New(&logrusHandler{
out: out,
level: level,
color: isTerminal(out),
}),
}
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
// logrusHandler is a slog.Handler that formats output to match logrus TextFormatter.
// TTY output: INFO[2006/01/02 15:04:05] message
// File output: time="2006/01/02 15:04:05" level=info msg="message"
type logrusHandler struct {
mu sync.Mutex
out *os.File
level slog.Level
color bool
}
// Enabled reports whether the handler will emit a record at the given level.
func (h *logrusHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
// Handle formats and writes a log record. TTY output is coloured; file output
// uses the logrus key=value text format.
func (h *logrusHandler) Handle(_ context.Context, r slog.Record) error {
label, name, code := logrusLevel(r.Level)
ts := r.Time.Format("2006/01/02 15:04:05")
var line string
if h.color {
line = fmt.Sprintf("\x1b[%dm%s\x1b[0m[%s] %s\n", code, label, ts, r.Message)
} else {
line = fmt.Sprintf("time=%q level=%s msg=%q\n", ts, name, r.Message)
}
h.mu.Lock()
defer h.mu.Unlock()
_, err := fmt.Fprint(h.out, line)
return err
}
// WithAttrs returns the handler unchanged; structured attributes are not used.
func (h *logrusHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
// WithGroup returns the handler unchanged; groups are not used.
func (h *logrusHandler) WithGroup(_ string) slog.Handler { return h }
// logrusLevel maps slog levels to the 4-char TTY label, lowercase file label, and ANSI colour code.
func logrusLevel(level slog.Level) (string, string, int) {
switch {
case level < slog.LevelInfo:
return "DEBU", "debug", 37 // gray
case level < slog.LevelWarn:
return "INFO", "info", 36 // cyan
case level < slog.LevelError:
return "WARN", "warning", 33 // yellow
default:
return "ERRO", "error", 31 // red
}
}
// isTerminal reports whether f is connected to a terminal.
func isTerminal(f *os.File) bool {
info, err := f.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
// Info logs a message at INFO level.
func (l *Logger) Info(args ...any) { l.sl.Info(fmt.Sprint(args...)) }
// Infof logs a formatted message at INFO level.
func (l *Logger) Infof(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) }
// Debug logs a message at DEBUG level.
func (l *Logger) Debug(args ...any) { l.sl.Debug(fmt.Sprint(args...)) }
// Debugf logs a formatted message at DEBUG level.
func (l *Logger) Debugf(format string, args ...any) { l.sl.Debug(fmt.Sprintf(format, args...)) }
// Warn logs a message at WARN level.
func (l *Logger) Warn(args ...any) { l.sl.Warn(fmt.Sprint(args...)) }
// Warnf logs a formatted message at WARN level.
func (l *Logger) Warnf(format string, args ...any) { l.sl.Warn(fmt.Sprintf(format, args...)) }
// Error logs a message at ERROR level.
func (l *Logger) Error(args ...any) { l.sl.Error(fmt.Sprint(args...)) }
// Errorf logs a formatted message at ERROR level.
func (l *Logger) Errorf(format string, args ...any) { l.sl.Error(fmt.Sprintf(format, args...)) }
// Printf logs a formatted message at INFO level.
func (l *Logger) Printf(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) }
// Fatal logs a message at ERROR level then exits with status 1.
func (l *Logger) Fatal(args ...any) { l.sl.Error(fmt.Sprint(args...)); os.Exit(1) }
// Fatalf logs a formatted message at ERROR level then exits with status 1.
func (l *Logger) Fatalf(format string, args ...any) {
l.sl.Error(fmt.Sprintf(format, args...))
os.Exit(1)
}
// PrettyPrint prints any value as indented JSON to stdout, for debugging.
func PrettyPrint(i any) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}
// CleanHTTPIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "localhost:<port>"
// CleanHTTPIP returns a human-readable address for log output.
// It translates [::]:<port> to localhost:<port>.
func CleanHTTPIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {

View File

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

@@ -2,138 +2,171 @@
package prometheus
import (
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/stats"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
// Registry is the Prometheus registry for Mailpit metrics
Registry = prometheus.NewRegistry()
// Metrics
totalMessages prometheus.Gauge
unreadMessages prometheus.Gauge
databaseSize prometheus.Gauge
messagesDeleted prometheus.Counter
smtpAccepted prometheus.Counter
smtpRejected prometheus.Counter
smtpIgnored prometheus.Counter
smtpAcceptedSize prometheus.Counter
uptime prometheus.Gauge
memoryUsage prometheus.Gauge
tagCounters *prometheus.GaugeVec
)
// InitMetrics initializes all Prometheus metrics
func initMetrics() {
// Create metrics
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_messages",
Help: "Total number of messages in the database",
})
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_messages_unread",
Help: "Number of unread messages in the database",
})
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_database_size_bytes",
Help: "Size of the database in bytes",
})
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_messages_deleted_total",
Help: "Total number of messages deleted",
})
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_accepted_total",
Help: "Total number of SMTP messages accepted",
})
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_rejected_total",
Help: "Total number of SMTP messages rejected",
})
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_ignored_total",
Help: "Total number of SMTP messages ignored (duplicates)",
})
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_accepted_size_bytes_total",
Help: "Total size of accepted SMTP messages in bytes",
})
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_uptime_seconds",
Help: "Uptime of Mailpit in seconds",
})
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_memory_usage_bytes",
Help: "Memory usage in bytes",
})
tagCounters = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "mailpit_tag_messages",
Help: "Number of messages per tag",
},
[]string{"tag"},
)
// Register metrics
Registry.MustRegister(totalMessages)
Registry.MustRegister(unreadMessages)
Registry.MustRegister(databaseSize)
Registry.MustRegister(messagesDeleted)
Registry.MustRegister(smtpAccepted)
Registry.MustRegister(smtpRejected)
Registry.MustRegister(smtpIgnored)
Registry.MustRegister(smtpAcceptedSize)
Registry.MustRegister(uptime)
Registry.MustRegister(memoryUsage)
Registry.MustRegister(tagCounters)
type gauge struct {
mu sync.RWMutex
val float64
}
func (g *gauge) Set(v float64) {
g.mu.Lock()
g.val = v
g.mu.Unlock()
}
func (g *gauge) get() float64 {
g.mu.RLock()
defer g.mu.RUnlock()
return g.val
}
type gaugeVec struct {
mu sync.RWMutex
label string
vals map[string]float64
}
func newGaugeVec(label string) *gaugeVec {
return &gaugeVec{label: label, vals: make(map[string]float64)}
}
func (v *gaugeVec) Set(labelVal string, val float64) {
v.mu.Lock()
v.vals[labelVal] = val
v.mu.Unlock()
}
func (v *gaugeVec) Reset() {
v.mu.Lock()
v.vals = make(map[string]float64)
v.mu.Unlock()
}
type entry struct {
name string
help string
typ string
g *gauge
vec *gaugeVec
}
var (
regMu sync.RWMutex
registry []entry
totalMessages = &gauge{}
unreadMessages = &gauge{}
databaseSize = &gauge{}
messagesDeleted = &gauge{}
smtpAccepted = &gauge{}
smtpRejected = &gauge{}
smtpIgnored = &gauge{}
smtpAcceptedSize = &gauge{}
uptime = &gauge{}
memoryUsage = &gauge{}
tagCounters = newGaugeVec("tag")
)
func register(name, help, typ string, g *gauge, vec *gaugeVec) {
regMu.Lock()
registry = append(registry, entry{name: name, help: help, typ: typ, g: g, vec: vec})
regMu.Unlock()
}
func initMetrics() {
register("mailpit_database_size_bytes", "Size of the database in bytes", "gauge", databaseSize, nil)
register("mailpit_memory_usage_bytes", "Memory usage in bytes", "gauge", memoryUsage, nil)
register("mailpit_messages", "Total number of messages in the database", "gauge", totalMessages, nil)
register("mailpit_messages_deleted_total", "Total number of messages deleted", "counter", messagesDeleted, nil)
register("mailpit_messages_unread", "Number of unread messages in the database", "gauge", unreadMessages, nil)
register("mailpit_smtp_accepted_size_bytes_total", "Total size of accepted SMTP messages in bytes", "counter", smtpAcceptedSize, nil)
register("mailpit_smtp_accepted_total", "Total number of SMTP messages accepted", "counter", smtpAccepted, nil)
register("mailpit_smtp_ignored_total", "Total number of SMTP messages ignored (duplicates)", "counter", smtpIgnored, nil)
register("mailpit_smtp_rejected_total", "Total number of SMTP messages rejected", "counter", smtpRejected, nil)
register("mailpit_tag_messages", "Number of messages per tag", "gauge", nil, tagCounters)
register("mailpit_uptime_seconds", "Uptime of Mailpit in seconds", "gauge", uptime, nil)
}
// UpdateMetrics updates all metrics with current values
func updateMetrics() {
info := stats.Load(false)
totalMessages.Set(float64(info.Messages))
unreadMessages.Set(float64(info.Unread))
databaseSize.Set(float64(info.DatabaseSize))
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
messagesDeleted.Set(float64(info.RuntimeStats.MessagesDeleted))
smtpAccepted.Set(float64(info.RuntimeStats.SMTPAccepted))
smtpRejected.Set(float64(info.RuntimeStats.SMTPRejected))
smtpIgnored.Set(float64(info.RuntimeStats.SMTPIgnored))
smtpAcceptedSize.Set(float64(info.RuntimeStats.SMTPAcceptedSize))
uptime.Set(float64(info.RuntimeStats.Uptime))
memoryUsage.Set(float64(info.RuntimeStats.Memory))
// Reset tag counters
tagCounters.Reset()
// Update tag counters
for tag, count := range info.Tags {
tagCounters.WithLabelValues(tag).Set(float64(count))
tagCounters.Set(tag, float64(count))
}
}
// GetHandler returns the Prometheus handler & disables double compression in middleware
func writeMetrics(w io.Writer) {
regMu.RLock()
entries := make([]entry, len(registry))
copy(entries, registry)
regMu.RUnlock()
sort.Slice(entries, func(i, j int) bool {
return entries[i].name < entries[j].name
})
for _, e := range entries {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n", e.name, e.help, e.name, e.typ)
if e.g != nil {
fmt.Fprintf(w, "%s %s\n", e.name, formatFloat(e.g.get()))
} else {
e.vec.mu.RLock()
keys := make([]string, 0, len(e.vec.vals))
snapshot := make(map[string]float64, len(e.vec.vals))
for k, v := range e.vec.vals {
keys = append(keys, k)
snapshot[k] = v
}
e.vec.mu.RUnlock()
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%s{%s=\"%s\"} %s\n", e.name, e.vec.label, escapeLabelValue(k), formatFloat(snapshot[k]))
}
}
}
}
func escapeLabelValue(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
func formatFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
// GetHandler returns the Prometheus metrics HTTP handler
func GetHandler() http.Handler {
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
DisableCompression: true,
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writeMetrics(w)
})
}
@@ -142,11 +175,9 @@ func StartUpdater() {
initMetrics()
updateMetrics()
// Start periodic updates
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
updateMetrics()
}
@@ -159,18 +190,15 @@ func StartSeparateServer() {
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
// Create a dedicated mux for the metrics server
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
mux.Handle("/metrics", GetHandler())
// Create a dedicated server instance
server := &http.Server{
Addr: config.PrometheusListen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// Start HTTP server
if err := server.ListenAndServe(); err != nil {
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
}
@@ -179,7 +207,6 @@ func StartSeparateServer() {
// GetMode returns the Prometheus run mode
func GetMode() string {
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
switch mode {
case "false", "":
return "disabled"

View File

@@ -0,0 +1,53 @@
// Package shortuuid provides a simple way to generate short, unique, alphanumeric identifiers.
// The generated IDs are 22 characters long and consist of uppercase letters, lowercase letters, and digits.
package shortuuid
import (
"encoding/binary"
"math/bits"
"github.com/google/uuid"
)
const (
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
length = 22
nDigits = 10
divisor = 839299365868340224 // 62^10, max power of 62 that fits in uint64
)
// New returns a 22-character alphanumeric unique identifier.
func New() string {
id := uuid.New()
num := [2]uint64{
binary.BigEndian.Uint64(id[8:]),
binary.BigEndian.Uint64(id[:8]),
}
buf := make([]byte, length)
var r uint64
i := length - 1
for num[1] > 0 || num[0] > 0 {
num, r = quoRem64(num, divisor)
for j := 0; j < nDigits && i >= 0; j++ {
buf[i] = alphabet[r%62]
r /= 62
i--
}
}
for ; i >= 0; i-- {
buf[i] = alphabet[0]
}
return string(buf)
}
// quoRem64 divides a 128-bit number (represented as [lo, hi] uint64) by v,
// returning the quotient and remainder.
func quoRem64(u [2]uint64, v uint64) ([2]uint64, uint64) {
var q [2]uint64
var r uint64
q[1], r = bits.Div64(0, u[1], v)
q[0], r = bits.Div64(r, u[0], v)
return q, r
}

View File

@@ -0,0 +1,52 @@
package shortuuid
import (
"regexp"
"testing"
)
// alphanumeric matches IDs that contain only digits and ASCII letters.
var alphanumeric = regexp.MustCompile(`^[0-9A-Za-z]+$`)
// TestLength verifies that every generated ID is exactly 22 characters long,
// including when the UUID encodes to a value with leading zero-padding.
func TestLength(t *testing.T) {
for range 100 {
id := New()
if len(id) != length {
t.Errorf("expected length %d, got %d: %q", length, len(id), id)
}
}
}
// TestAlphanumeric verifies that no ID contains hyphens, underscores, or any
// other non-alphanumeric character that would be unsafe in a URL path segment.
func TestAlphanumeric(t *testing.T) {
for range 100 {
id := New()
if !alphanumeric.MatchString(id) {
t.Errorf("non-alphanumeric characters in ID: %q", id)
}
}
}
// TestUnique verifies that IDs are unique across a large sample. Collisions are
// cryptographically implausible given the 122-bit UUID entropy, so any hit here
// indicates a bug in the encoding (e.g. truncation, constant output).
func TestUnique(t *testing.T) {
seen := make(map[string]struct{}, 1000000)
for range 1000000 {
id := New()
if _, exists := seen[id]; exists {
t.Fatalf("duplicate ID generated: %q", id)
}
seen[id] = struct{}{}
}
}
// BenchmarkNew measures the cost of generating a single ID, including UUID generation.
func BenchmarkNew(b *testing.B) {
for b.Loop() {
_ = New()
}
}

View File

@@ -10,20 +10,25 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/pkg/errors"
)
// Wrapper to forward messages if configured
func autoForwardMessage(from string, data *[]byte) {
func autoForwardMessage(from string, data *[]byte) error {
if config.SMTPForwardConfig.Host == "" {
return
return nil
}
if err := forward(from, *data); err != nil {
logger.Log().Errorf("[forward] error: %s", err.Error())
} else {
logger.Log().Debugf("[forward] message from %s to %s via %s:%d",
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
return fmt.Errorf("[forward] error: %w", err)
}
logger.Log().Debugf(
"[forward] message from %s to %s via %s:%d",
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port,
)
return nil
}
func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {
@@ -54,6 +59,7 @@ func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr stri
// Set the hostname for HELO/EHLO
if hostname, err := os.Hostname(); err == nil {
if err := client.Hello(hostname); err != nil {
_ = client.Close()
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
}
}
@@ -103,11 +109,14 @@ 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 {
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
}
}
}

View File

@@ -12,11 +12,12 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/shortuuid"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
)
var (
@@ -73,10 +74,36 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtp
}
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
autoRelayMessage(from, to, &data)
if relayErr := autoRelayMessage(from, to, &data); relayErr != nil {
logger.Log().Error(relayErr.Error())
if config.SMTPRelayConfig.ForwardSMTPErrors {
for {
unwrappedErr := errors.Unwrap(relayErr)
if unwrappedErr == nil {
break
}
relayErr = unwrappedErr
}
return "", relayErr
}
}
// if enabled, this will forward a copy to preconfigured addresses
autoForwardMessage(from, &data)
if forwardErr := autoForwardMessage(from, &data); forwardErr != nil {
logger.Log().Error(forwardErr.Error())
if config.SMTPForwardConfig.ForwardSMTPErrors {
for {
unwrappedErr := errors.Unwrap(forwardErr)
if unwrappedErr == nil {
break
}
forwardErr = unwrappedErr
}
return "", forwardErr
}
}
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
@@ -220,20 +247,36 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
},
}
if config.MaxMessageSize > 0 {
srv.MaxSize = config.MaxMessageSize * 1024 * 1024
}
if config.Label != "" {
srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthMechs = map[string]bool{
"CRAM-MD5": false,
"PLAIN": true,
"LOGIN": true,
}
}
if auth.SMTPCredentials != nil {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthMechs = map[string]bool{
"CRAM-MD5": false,
"PLAIN": true,
"LOGIN": true,
}
srv.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthMechs = map[string]bool{
"CRAM-MD5": false,
"PLAIN": true,
"LOGIN": true,
}
srv.AuthHandler = authHandlerAny
}

View File

@@ -2,7 +2,6 @@ package smtpd
import (
"crypto/tls"
"errors"
"fmt"
"net/smtp"
"os"
@@ -11,10 +10,11 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/pkg/errors"
)
// Wrapper to auto relay messages if configured
func autoRelayMessage(from string, to []string, data *[]byte) {
func autoRelayMessage(from string, to []string, data *[]byte) error {
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
filteredTo := []string{}
for _, address := range to {
@@ -29,16 +29,18 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
}
if len(to) == 0 {
return
return nil
}
if config.SMTPRelayAll {
if err := Relay(from, to, *data); err != nil {
logger.Log().Errorf("[relay] error: %s", err.Error())
} else {
logger.Log().Debugf("[relay] sent message to %s from %s via %s:%d",
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
return errors.WithMessage(err, "[relay] error")
}
logger.Log().Debugf(
"[relay] sent message to %s from %s via %s:%d",
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,
)
} else if config.SMTPRelayMatchingRegexp != nil {
filtered := []string{}
for _, t := range to {
@@ -48,16 +50,20 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
}
if len(filtered) == 0 {
return
return nil
}
if err := Relay(from, filtered, *data); err != nil {
logger.Log().Errorf("[relay] error: %s", err.Error())
} else {
logger.Log().Debugf("[relay] auto-relay message to %s from %s via %s:%d",
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
return errors.WithMessage(err, "[relay] error")
}
logger.Log().Debugf(
"[relay] auto-relay message to %s from %s via %s:%d",
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,
)
}
return nil
}
func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {
@@ -134,26 +140,29 @@ func Relay(from string, to []string, msg []byte) error {
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
return errors.WithMessage(err, "error sending MAIL command")
}
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.SMTPRelayConfig.ForwardSMTPErrors {
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
}
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("error response to DATA command: %s", err.Error())
return errors.WithMessage(err, "error response to DATA command")
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
return errors.WithMessage(err, "error sending message")
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
return errors.WithMessage(err, "error closing connection")
}
return c.Quit()
@@ -186,7 +195,10 @@ type loginAuth struct {
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
return &loginAuth{
username,
password,
}
}
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {

View File

@@ -15,6 +15,7 @@ import (
"io/fs"
"log"
"net"
"net/mail"
"os"
"regexp"
"strconv"
@@ -336,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
@@ -361,8 +362,9 @@ func (s *session) serve() {
// otherwise results in a 5s timeout for each connection
defer func(c net.Conn) { _ = c.Close() }(s.conn)
var gotEHLO bool
var from string
var gotFrom bool
var gotFROM bool
var to []string
var hasRejectedRecipients bool
var buffer bytes.Buffer
@@ -396,8 +398,9 @@ loop:
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
gotEHLO = true
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -406,8 +409,9 @@ loop:
s.writef("%s", s.makeEHLOResponse())
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
gotEHLO = true
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -420,10 +424,22 @@ loop:
s.writef("530 5.7.0 Authentication required")
break
}
if !gotEHLO {
s.writef("503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)")
break
}
if to != nil {
s.writef("503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)")
break
}
match := mailFromRE.FindStringSubmatch(args)
match, err := extractAndValidateAddress(mailFromRE, args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
if err != nil {
s.writef("%s", err.Error())
} else {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
}
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Sender.Trigger(); fail {
@@ -437,7 +453,7 @@ loop:
if sizeMatch == nil {
// ignore other parameter
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
} else {
// Enforce the maximum message size if one is set.
@@ -449,13 +465,13 @@ loop:
s.writef("%s", err.Error())
} else { // SIZE ok
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
}
}
} else { // No parameters after FROM
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
}
}
@@ -472,14 +488,18 @@ loop:
s.writef("530 5.7.0 Authentication required")
break
}
if !gotFrom {
if !gotFROM {
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
break
}
match := rcptToRE.FindStringSubmatch(args)
match, err := extractAndValidateAddress(rcptToRE, args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
if err != nil {
s.writef("%s", err.Error())
} else {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
}
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Recipient.Trigger(); fail {
@@ -515,7 +535,7 @@ loop:
break
}
hasRecipients := len(to) > 0 || hasRejectedRecipients
if !gotFrom || !hasRecipients {
if !gotFROM || !hasRecipients {
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
break
}
@@ -593,7 +613,7 @@ loop:
// Reset for next mail.
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -607,7 +627,7 @@ loop:
}
s.writef("250 2.0.0 Ok")
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -616,9 +636,12 @@ 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 len(xCParse) != 2 {
continue
}
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
s.xClientADDR = xCParse[1]
}
@@ -684,7 +707,7 @@ loop:
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
s.remoteName = ""
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -706,7 +729,7 @@ loop:
}
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
if gotFrom || len(to) > 0 {
if gotFROM || len(to) > 0 {
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
break
}
@@ -766,7 +789,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))
}
@@ -811,9 +834,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 = ""
@@ -859,10 +882,14 @@ func (s *session) readData() ([]byte, error) {
// TODO: Work out what to do with multiple to addresses.
func (s *session) makeHeaders(to []string) []byte {
var buffer bytes.Buffer
if len(to) == 0 {
return buffer.Bytes()
}
now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)")
buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP))
buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName))
buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now))
fmt.Fprintf(&buffer, "Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP)
fmt.Fprintf(&buffer, " by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName)
fmt.Fprintf(&buffer, " for <%s>; %s\r\n", to[0], now)
return buffer.Bytes()
}
@@ -908,6 +935,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
}
@@ -975,6 +1006,12 @@ func (s *session) handleAuthPlain(arg string) (bool, error) {
// Validate credentials.
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
if authenticated {
uname := string(parts[1])
s.username = &uname
} else {
s.username = nil
}
return authenticated, err
}
@@ -1008,3 +1045,35 @@ func (s *session) handleAuthCramMD5() (bool, error) {
return authenticated, err
}
// Extract and validate email address from a regex match.
// This ensures that only RFC 5322 compliant email addresses are accepted (if set).
func extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) {
match := re.FindStringSubmatch(args)
if match == nil {
return nil, nil
}
if strings.Contains(match[1], " ") {
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
}
// first argument will be the email address, validate it if not empty
if match[1] != "" {
a, err := mail.ParseAddress(match[1])
if err != nil {
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
}
// https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1
// RFC states that the local part of an email address SHOULD not exceed 64 characters
// and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620
// it appears that investigated mail servers do not actually implement this limit, but rather enforce
// a much larger limit (ie: 1024 characters).
if len(a.Address) > 1024 {
return nil, errors.New("500 The address is too long")
}
}
return match, nil
}

View File

@@ -104,6 +104,19 @@ func TestCmdEHLO(t *testing.T) {
// See RFC 2821 section 4.1.4 for more detail.
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
// test invalid addresses & header injection
cmdCode(t, conn, "RCPT TO: <recipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipient@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
cmdCode(t, conn, "RCPT TO:<recipientexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient@test@example.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient@@example.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "501")
cmdCode(t, conn, "RCPT TO:<recipient\rexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <>", "501") // empty address not allowed here
cmdCode(t, conn, "EHLO host.example.com", "250")
cmdCode(t, conn, "DATA", "503")
@@ -111,6 +124,41 @@ func TestCmdEHLO(t *testing.T) {
_ = conn.Close()
}
func TestCmdMAILBeforeEHLO(t *testing.T) {
conn := newConn(t, &Server{})
// RFC 5321 §4.1.4 — Order of Commands states (emphasis added):
// “The SMTP client MUST issue HELO or EHLO before any other SMTP commands.”
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "503")
cmdCode(t, conn, "QUIT", "221")
_ = conn.Close()
}
func TestCmdMAILAfterRCPT(t *testing.T) {
conn := newConn(t, &Server{})
// Send EHLO, expect greeting
cmdCode(t, conn, "EHLO host.example.com", "250")
// Send MAIL FROM
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
// Send RCPT TO
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
// MAIL FROM must not come after RCPT TO in the same transaction
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "503")
// RSET to clear the transaction
cmdCode(t, conn, "RSET", "250")
// Now the MAIL FROM should be accepted
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "250")
cmdCode(t, conn, "QUIT", "221")
_ = conn.Close()
}
func TestCmdRSET(t *testing.T) {
conn := newConn(t, &Server{})
cmdCode(t, conn, "EHLO host.example.com", "250")
@@ -131,7 +179,7 @@ func TestCmdMAIL(t *testing.T) {
// MAIL with no FROM arg should return 501 syntax error
cmdCode(t, conn, "MAIL", "501")
// MAIL with empty FROM arg should return 501 syntax error
// // MAIL with empty FROM arg should return 501 syntax error
cmdCode(t, conn, "MAIL FROM:", "501")
cmdCode(t, conn, "MAIL FROM: ", "501")
cmdCode(t, conn, "MAIL FROM: ", "501")
@@ -145,6 +193,20 @@ func TestCmdMAIL(t *testing.T) {
// MAIL with seemingly valid but noncompliant FROM arg (double space after the colon) should return 501 syntax error
cmdCode(t, conn, "MAIL FROM: <sender@example.com>", "501")
// test invalid addresses & header injection
cmdCode(t, conn, "MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersender@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
cmdCode(t, conn, "MAIL FROM:<sender\rexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "501")
cmdCode(t, conn, "MAIL FROM:<senderexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@test@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: < sender@example.com >", "553")
cmdCode(t, conn, "MAIL FROM: < sender@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@example.com >", "553")
// MAIL with valid SIZE parameter should return 250 Ok
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE=1000", "250")
@@ -153,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
@@ -212,6 +274,7 @@ func TestCmdRCPT(t *testing.T) {
cmdCode(t, conn, "RCPT TO:", "501")
cmdCode(t, conn, "RCPT TO: ", "501")
cmdCode(t, conn, "RCPT TO: ", "501")
cmdCode(t, conn, "RCPT TO:<@route.example user@example.com>", "553")
// RCPT with valid TO arg should return 250 Ok
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
@@ -716,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] = ""
}
@@ -821,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

@@ -9,13 +9,20 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
// 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 +42,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()
}
@@ -56,6 +63,7 @@ func pruneMessages() {
start := time.Now()
ids := []string{}
idsSeen := make(map[string]bool)
var prunedSize uint64
var size float64 // use float64 for rqlite compatibility
@@ -80,6 +88,7 @@ func pruneMessages() {
return
}
ids = append(ids, id)
idsSeen[id] = true
prunedSize = prunedSize + uint64(size)
},
@@ -107,8 +116,9 @@ func pruneMessages() {
return
}
if !tools.InArray(id, ids) {
if _, exists := idsSeen[id]; !exists {
ids = append(ids, id)
idsSeen[id] = true
prunedSize = prunedSize + uint64(size)
}
@@ -128,7 +138,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 +164,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"
@@ -16,12 +19,12 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/shortuuid"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime/v2"
"github.com/leporo/sqlf"
"github.com/lithammer/shortuuid/v4"
)
// Store will save an email to the database tables.
@@ -275,8 +278,19 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
if len(results) > 0 {
ids := make([]string, len(results))
for i, m := range results {
ids[i] = m.ID
}
tagMap := getTagsForIDs(ids)
for i, m := range results {
if tags, ok := tagMap[m.ID]; ok {
results[i].Tags = tags
} else {
results[i].Tags = []string{}
}
}
}
dbLastAction = time.Now()
@@ -505,6 +519,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
}
@@ -537,22 +559,109 @@ func LatestID(r *http.Request) (string, error) {
// MarkRead will mark a message as read
func MarkRead(ids []string) error {
for _, id := range ids {
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if len(ids) == 0 {
return nil
}
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
placeholder := `(?` + strings.Repeat(",?", len(ids)-1) + `)`
// Find which messages are actually unread (will change state)
toUpdate := []string{}
rows, err := db.Query(fmt.Sprintf(`SELECT ID FROM %s WHERE Read = 0 AND ID IN %s`, tenant("mailbox"), placeholder), args...) // #nosec
if err != nil {
return err
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
}
toUpdate = append(toUpdate, id)
}
_ = rows.Close()
d := struct {
if len(toUpdate) == 0 {
return nil
}
updateArgs := make([]any, len(toUpdate))
for i, id := range toUpdate {
updateArgs[i] = id
}
updatePlaceholder := `(?` + strings.Repeat(",?", len(toUpdate)-1) + `)`
if _, err := db.Exec(fmt.Sprintf(`UPDATE %s SET Read = 1 WHERE ID IN %s`, tenant("mailbox"), updatePlaceholder), updateArgs...); err != nil { // #nosec
return err
}
for _, id := range toUpdate {
logger.Log().Debugf("[db] marked message %s as read", id)
websockets.Broadcast("update", struct {
ID string
Read bool
}{ID: id, Read: true}
}{ID: id, Read: true})
}
websockets.Broadcast("update", d)
BroadcastMailboxStats()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(ids []string) error {
if len(ids) == 0 {
return nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
placeholder := `(?` + strings.Repeat(",?", len(ids)-1) + `)`
// Find which messages are actually read (will change state)
toUpdate := []string{}
rows, err := db.Query(fmt.Sprintf(`SELECT ID FROM %s WHERE Read = 1 AND ID IN %s`, tenant("mailbox"), placeholder), args...) // #nosec
if err != nil {
return err
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return err
}
toUpdate = append(toUpdate, id)
}
_ = rows.Close()
if len(toUpdate) == 0 {
return nil
}
updateArgs := make([]any, len(toUpdate))
for i, id := range toUpdate {
updateArgs[i] = id
}
updatePlaceholder := `(?` + strings.Repeat(",?", len(toUpdate)-1) + `)`
if _, err := db.Exec(fmt.Sprintf(`UPDATE %s SET Read = 0 WHERE ID IN %s`, tenant("mailbox"), updatePlaceholder), updateArgs...); err != nil { // #nosec
return err
}
dbLastAction = time.Now()
for _, id := range toUpdate {
logger.Log().Debugf("[db] marked message %s as unread", id)
websockets.Broadcast("update", struct {
ID string
Read bool
}{ID: id, Read: false})
}
BroadcastMailboxStats()
@@ -610,33 +719,6 @@ func MarkAllUnread() error {
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(ids []string) error {
for _, id := range ids {
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
}
BroadcastMailboxStats()
return nil
}
// DeleteMessages deletes one or more messages in bulk
func DeleteMessages(ids []string) error {
if len(ids) == 0 {
@@ -645,7 +727,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 +767,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

@@ -1,6 +1,7 @@
package storage
import (
"os"
"testing"
"time"
@@ -15,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()
@@ -53,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()
@@ -204,3 +205,99 @@ func BenchmarkImportMime(b *testing.B) {
}
}
func TestInlineImageContentIdHandling(t *testing.T) {
setup("")
defer Close()
t.Log("Testing inline content handling")
// Test case: Proper inline image with Content-Disposition: inline
inlineAttachment, err := os.ReadFile("testdata/inline-attachment.eml")
if err != nil {
t.Fatalf("Failed to read test email: %v", err)
}
storedMessage, err := Store(&inlineAttachment, nil)
if err != nil {
t.Fatal("Failed to store test case 1:", err)
}
msg, err := GetMessage(storedMessage)
if err != nil {
t.Fatal("Failed to retrieve test case 1:", err)
}
// Assert
if len(msg.Inline) != 1 {
t.Errorf("Test case 1: Expected 1 inline attachment, got %d", len(msg.Inline))
}
if len(msg.Attachments) != 0 {
t.Errorf("Test case 1: Expected 0 regular attachments, got %d", len(msg.Attachments))
}
if msg.Inline[0].ContentID != "test1@example.com" {
t.Errorf("Test case 1: Expected ContentID 'test1@example.com', got '%s'", msg.Inline[0].ContentID)
}
}
func TestRegularAttachmentHandling(t *testing.T) {
setup("")
defer Close()
t.Log("Testing regular attachment handling")
// Test case: Regular attachment without Content-ID
regularAttachment, err := os.ReadFile("testdata/regular-attachment.eml")
if err != nil {
t.Fatalf("Failed to read test email: %v", err)
}
storedMessage, err := Store(&regularAttachment, nil)
if err != nil {
t.Fatal("Failed to store test case 3:", err)
}
msg, err := GetMessage(storedMessage)
if err != nil {
t.Fatal("Failed to retrieve test case 3:", err)
}
// Assert
if len(msg.Inline) != 0 {
t.Errorf("Test case 3: Expected 0 inline attachments, got %d", len(msg.Inline))
}
if len(msg.Attachments) != 1 {
t.Errorf("Test case 3: Expected 1 regular attachment, got %d", len(msg.Attachments))
}
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) {
setup("")
defer Close()
t.Log("Testing mixed attachment handling")
// Mixed scenario with both inline and regular attachment
mixedAttachment, err := os.ReadFile("testdata/mixed-attachment.eml")
if err != nil {
t.Fatalf("Failed to read test email: %v", err)
}
storedMessage, err := Store(&mixedAttachment, nil)
if err != nil {
t.Fatal("Failed to store test case 4:", err)
}
msg, err := GetMessage(storedMessage)
if err != nil {
t.Fatal("Failed to retrieve test case 4:", err)
}
// Assert: Should have 1 inline (with ContentID) and 1 attachment (without ContentID)
if len(msg.Inline) != 1 {
t.Errorf("Test case 4: Expected 1 inline attachment, got %d", len(msg.Inline))
}
if len(msg.Attachments) != 1 {
t.Errorf("Test case 4: Expected 1 regular attachment, got %d", len(msg.Attachments))
}
if msg.Inline[0].ContentID != "inline@example.com" {
t.Errorf("Test case 4: Expected inline ContentID 'inline@example.com', got '%s'", msg.Inline[0].ContentID)
}
if msg.Attachments[0].ContentID != "" {
t.Errorf("Test case 4: Expected attachment ContentID to be empty, got '%s'", msg.Attachments[0].ContentID)
}
}

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

@@ -84,7 +84,7 @@ func dbApplySchemas() error {
continue
}
schemaID := strings.TrimRight(s.Name(), ".sql")
schemaID := strings.TrimSuffix(s.Name(), ".sql")
if !semver.IsValid(schemaID) {
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())

View File

@@ -80,17 +80,25 @@ 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]
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
if len(results) > 0 {
ids := make([]string, len(results))
for i, m := range results {
ids[i] = m.ID
}
tagMap := getTagsForIDs(ids)
for i, m := range results {
if tags, ok := tagMap[m.ID]; ok {
results[i].Tags = tags
} else {
results[i].Tags = []string{}
}
}
}
elapsed := time.Since(tsStart)
@@ -196,7 +204,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 +311,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 +448,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 +467,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 +482,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

@@ -8,7 +8,6 @@ import (
"regexp"
"sort"
"strings"
"sync"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
@@ -19,45 +18,62 @@ import (
var (
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
addTagMutex sync.RWMutex
)
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) ([]string, error) {
// Clean and deduplicate incoming tags (case-insensitive)
seen := make(map[string]struct{})
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
for _, t := range applyTags {
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
if t == "" || !config.ValidTagRegexp.MatchString(t) {
continue
}
lc := strings.ToLower(t)
if _, exists := seen[lc]; exists {
continue
}
seen[lc] = struct{}{}
applyTags = append(applyTags, t)
}
// Fetch existing tags once and index by lowercase name for O(1) lookup
currentTags := getMessageTags(id)
currentSet := make(map[string]struct{}, len(currentTags))
for _, t := range currentTags {
currentSet[strings.ToLower(t)] = struct{}{}
}
// Build apply set for O(1) lookup when computing deletions
applySet := make(map[string]struct{}, len(applyTags))
for _, t := range applyTags {
applySet[strings.ToLower(t)] = struct{}{}
}
// Add tags not already on the message
tagNames := []string{}
for _, t := range applyTags {
if _, exists := currentSet[strings.ToLower(t)]; exists {
continue
}
name, err := addMessageTag(id, t)
if err != nil {
return []string{}, err
}
tagNames = append(tagNames, name)
}
if origTagCount > 0 {
currentTags = getMessageTags(id)
for _, t := range currentTags {
if !tools.InArray(t, applyTags) {
if err := deleteMessageTag(id, t); err != nil {
return []string{}, err
}
}
// Delete tags removed from the message in a single batch query
toDelete := []string{}
for _, t := range currentTags {
if _, exists := applySet[strings.ToLower(t)]; !exists {
toDelete = append(toDelete, t)
}
}
if len(toDelete) > 0 {
if err := deleteMessageTags(id, toDelete); err != nil {
return []string{}, err
}
}
@@ -73,57 +89,63 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
// AddMessageTag adds a tag to a message
func addMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
var tagID int
var foundName sql.NullString
q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name)
// if tag exists - add tag to message
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
addTagMutex.Unlock()
// check message does not already have this tag
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return "", err
}
if exists > 0 {
// already exists
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return foundName.String, err
}
// new tag, add to the database
if _, err := sqlf.InsertInto(tenant("tags")).
Set("Name", name).
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
// Ensure the tag row exists; the UNIQUE index on Name makes concurrent inserts safe
if _, err := db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (Name) VALUES (?)`, tenant("tags")), name); err != nil { // #nosec
return name, err
}
addTagMutex.Unlock()
var tagID int
var foundName string
// add tag to the message
return addMessageTag(id, name)
if err := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name).
QueryRowAndClose(context.TODO(), db); err != nil {
return name, err
}
// Check message does not already have this tag
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return "", err
}
if exists > 0 {
return foundName, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return foundName, err
}
// deleteMessageTags deletes multiple tags from a message in a single query
func deleteMessageTags(id string, names []string) error {
args := make([]any, 1+len(names))
args[0] = id
for i, n := range names {
args[i+1] = n
}
query := fmt.Sprintf(
`DELETE FROM %s WHERE ID = ? AND TagID IN (SELECT ID FROM %s WHERE Name IN (?%s))`,
tenant("message_tags"), tenant("tags"), strings.Repeat(",?", len(names)-1),
) // #nosec
if _, err := db.Exec(query, args...); err != nil {
return err
}
return pruneUnusedTags()
}
// DeleteMessageTag deletes a tag from a message
@@ -147,7 +169,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 +191,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())
@@ -340,6 +362,43 @@ func (d Metadata) tagsFromPlusAddresses() []string {
return tools.SetTagCasing(tags)
}
// getTagsForIDs fetches tags for a set of message IDs in a single query,
// returning a map of message ID to tag names.
func getTagsForIDs(ids []string) map[string][]string {
result := make(map[string][]string, len(ids))
if len(ids) == 0 {
return result
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
query := fmt.Sprintf(
`SELECT mt.ID, t.Name FROM %s t JOIN %s mt ON t.ID = mt.TagID WHERE mt.ID IN (?%s) ORDER BY mt.ID, t.Name`,
tenant("Tags"), tenant("message_tags"), strings.Repeat(",?", len(ids)-1),
) // #nosec
rows, err := db.Query(query, args...)
if err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return result
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return result
}
result[id] = append(result[id], name)
}
return result
}
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
@@ -352,7 +411,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)
}

View File

@@ -0,0 +1,20 @@
From: sender@example.com
To: recipient@example.com
Subject: Test inline image proper
MIME-Version: 1.0
Content-Type: multipart/related; boundary="boundary123"
--boundary123
Content-Type: text/html; charset=utf-8
<html><body><img src="cid:test1@example.com" alt="Test"/></body></html>
--boundary123
Content-Type: image/png; name="test1.png"
Content-Disposition: inline; filename="test1.png"
Content-ID: <test1@example.com>
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
--boundary123--

View File

@@ -0,0 +1,27 @@
From: sender@example.com
To: recipient@example.com
Subject: Test mixed attachments
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="boundary111"
--boundary111
Content-Type: text/html; charset=utf-8
<html><body><img src="cid:inline@example.com" alt="Inline"/><p>Document attached</p></body></html>
--boundary111
Content-Type: image/png; name="inline.png"
Content-Disposition: inline; filename="inline.png"
Content-ID: <inline@example.com>
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
--boundary111
Content-Type: application/pdf; name="document.pdf"
Content-Disposition: attachment; filename="document.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
--boundary111--

View File

@@ -0,0 +1,19 @@
From: sender@example.com
To: recipient@example.com
Subject: Test regular attachment
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="boundary789"
--boundary789
Content-Type: text/html; charset=utf-8
<html><body><p>Message with regular attachment</p></body></html>
--boundary789
Content-Type: application/pdf; name="document.pdf"
Content-Disposition: attachment; filename="document.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
--boundary789--

View File

@@ -57,7 +57,7 @@ func createSearchText(env *enmime.Envelope) string {
b.WriteString(env.GetHeader("Reply-To") + " ")
b.WriteString(env.GetHeader("Return-Path") + " ")
h := html2text.Strip(env.HTML, true)
h, _ := html2text.Strip(env.HTML, true)
if h != "" {
b.WriteString(h + " ")
} else {

View File

@@ -2,20 +2,23 @@ package tools
import (
"os"
"path/filepath"
)
// IsFile returns whether a file exists and is readable
// IsFile returns whether a path exists and is a regular file.
// Symlinks are deliberately rejected to prevent following links to
// arbitrary files outside the intended location.
func IsFile(path string) bool {
f, err := os.Open(filepath.Clean(path))
defer func() { _ = f.Close() }()
return err == nil
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode().IsRegular()
}
// IsDir returns whether a path is a directory
func IsDir(path string) bool {
info, err := os.Stat(path)
if err != nil || os.IsNotExist(err) || !info.IsDir() {
if err != nil || !info.IsDir() {
return false
}

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

@@ -0,0 +1,39 @@
package tools
import (
"net"
"net/url"
)
// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate().
// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique
// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses
// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network.
var cgnatRange = func() *net.IPNet {
_, cidr, _ := net.ParseCIDR("100.64.0.0/10")
return cidr
}()
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
// IsLoopback — 127.0.0.0/8, ::1
// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16
// IsUnspecified — 0.0.0.0, ::
// IsMulticast — 224.0.0.0/4, ff00::/8
// CGNAT — 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT)
func IsInternalIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
cgnatRange.Contains(ip)
}
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
func IsValidLinkURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != ""
}

View File

@@ -20,13 +20,14 @@ func CreateSnippet(text, html string) string {
}
if html != "" {
data := html2text.Strip(html, false)
data, err := html2text.Strip(html, false)
if err == nil {
if len(data) <= limit {
return data
}
if len(data) <= limit {
return data
return truncate(data, limit) + "..."
}
return truncate(data, limit) + "..."
}
if text != "" {

View File

@@ -10,7 +10,7 @@ import (
var (
// Invalid tag characters regex
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`)
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.@]`)
// Regex to catch multiple spaces
multiSpaceRe = regexp.MustCompile(`(\s+)`)
@@ -19,14 +19,21 @@ var (
TagsTitleCase bool
)
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters.
// If the tag is longer than 100 characters, it is truncated.
func CleanTag(s string) string {
return strings.TrimSpace(
t := strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
if len(t) > 100 {
return t[:100]
}
return t
}
// SetTagCasing returns the slice of tags, title-casing if set

View File

@@ -1,10 +1,71 @@
package tools
import (
"net"
"reflect"
"testing"
)
func TestIsInternalIP(t *testing.T) {
internal := []string{
"127.0.0.1", // loopback
"::1", // IPv6 loopback
"10.0.0.1", // private
"172.16.0.1", // private
"192.168.1.1", // private
"169.254.1.1", // link-local unicast
"fe80::1", // IPv6 link-local
"0.0.0.0", // unspecified
"224.0.0.1", // multicast
"100.64.0.1", // CGNAT start
"100.127.255.255", // CGNAT end
}
external := []string{
"8.8.8.8",
"1.1.1.1",
"100.128.0.1", // just outside CGNAT range
}
for _, s := range internal {
ip := net.ParseIP(s)
if !IsInternalIP(ip) {
t.Errorf("expected %s to be internal", s)
}
}
for _, s := range external {
ip := net.ParseIP(s)
if IsInternalIP(ip) {
t.Errorf("expected %s to be external", s)
}
}
}
func TestIsValidLinkURL(t *testing.T) {
valid := []string{
"http://example.com",
"https://example.com",
"https://example.com/path?q=1#anchor",
}
invalid := []string{
"",
"ftp://example.com",
"example.com",
"//example.com",
"https://",
}
for _, s := range valid {
if !IsValidLinkURL(s) {
t.Errorf("expected %q to be a valid link URL", s)
}
}
for _, s := range invalid {
if IsValidLinkURL(s) {
t.Errorf("expected %q to be an invalid link URL", s)
}
}
}
func TestArgsParser(t *testing.T) {
tests := map[string][]string{}
tests["this is a test"] = []string{"this", "is", "a", "test"}
@@ -33,7 +94,8 @@ func TestCleanTag(t *testing.T) {
tests["thiS IS a Test :-)"] = "thiS IS a Test -"
tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -"
tests["this_is-a test "] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a @ test"
tests["this is a long tag title with more than 100 characters, which should get automatically truncated to 100 characters"] = "this is a long tag title with more than 100 characters which should get automatically truncated to 1"
for search, expected := range tests {
res := CleanTag(search)

2571
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",
@@ -27,20 +27,22 @@
"timezones-list": "^3.0.3",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
"vue-router": "^5.0.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",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.25.0",
"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

@@ -23,10 +23,12 @@ import (
"path"
"regexp"
"strings"
"time"
"net"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/mneis/go-telnet"
flag "github.com/spf13/pflag"
)
@@ -119,30 +121,49 @@ func Run() {
socketAddr, isSocket := socketAddress(SMTPAddr)
// handles `sendmail -bs`
// telnet directly to SMTP
// relay stdin/stdout to SMTP connection
if UseB && UseS {
var caller = telnet.StandardCaller
switch isSocket {
case true:
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
default:
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
network := "tcp"
addr := SMTPAddr
if isSocket {
network = "unix"
addr = socketAddr
}
conn, err := net.Dial(network, addr)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer func() { _ = conn.Close() }()
done := make(chan struct{})
go func() {
_, _ = io.Copy(os.Stdout, conn)
close(done)
}()
_, _ = io.Copy(conn, os.Stdin)
if cw, ok := conn.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
select {
case <-done:
case <-time.After(30 * time.Second):
}
return
}
body, err := io.ReadAll(os.Stdin)
const maxMessageSize = 1000 * 1024 * 1024
body, err := io.ReadAll(io.LimitReader(os.Stdin, maxMessageSize+1))
if err != nil {
fmt.Fprintln(os.Stderr, "error reading stdin")
os.Exit(11)
}
if len(body) > maxMessageSize {
fmt.Fprintf(os.Stderr, "message exceeds %d MiB size cap\n", maxMessageSize/(1024*1024))
os.Exit(11)
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {

View File

@@ -10,8 +10,6 @@ import (
"net/smtp"
"os"
"strings"
"github.com/axllent/mailpit/internal/logger"
)
// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.
@@ -29,6 +27,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
return fmt.Errorf("no To addresses specified")
}
if err := validateLine(fromAddress.Address); err != nil {
return err
}
for _, recipient := range to {
if err := validateLine(recipient); err != nil {
return err
}
}
if !isSocket {
return sendMail(addr, nil, fromAddress.Address, to, msg)
}
@@ -40,16 +47,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
client, err := smtp.NewClient(conn, "")
if err != nil {
_ = conn.Close()
return err
}
defer func() { _ = client.Close() }()
// Set the sender
if err := client.Mail(fromAddress.Address); err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
return fmt.Errorf("error setting sender: %w", err)
}
// Set the recipient
for _, a := range to {
if err := client.Rcpt(a); err != nil {
return err
@@ -61,36 +67,30 @@ func Send(addr string, from string, to []string, msg []byte) error {
return err
}
_, err = wc.Write(msg)
if err != nil {
if _, err := wc.Write(msg); err != nil {
return err
}
err = wc.Close()
if err != nil {
if err := wc.Close(); err != nil {
return err
}
return nil
return client.Quit()
}
func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
if err := validateLine(from); err != nil {
return err
}
for _, recipient := range to {
if err := validateLine(recipient); err != nil {
return err
}
}
c, err := smtp.Dial(addr)
if err != nil {
return err
}
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,9 +6,9 @@ import (
"fmt"
"net/http"
"net/mail"
"net/url"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
)
// GetMessage (method: GET) returns the Message as JSON
@@ -31,9 +31,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -77,9 +75,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -132,10 +128,8 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
id := r.PathValue("id")
partID := r.PathValue("partID")
if id == "latest" {
var err error
@@ -158,7 +152,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)
}
@@ -182,9 +176,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
dl := r.FormValue("dl")
if id == "latest" {

View File

@@ -11,7 +11,6 @@ import (
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime/v2"
)
@@ -35,8 +34,7 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -105,8 +103,7 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -158,8 +155,7 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error

View File

@@ -10,11 +10,10 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/shortuuid"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
@@ -45,9 +44,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
msg, err := storage.GetMessageRaw(id)
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -42,11 +43,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
return
}
if config.MaxMessageSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
decoder := json.NewDecoder(r.Body)
data := sendMessageParams{}
if err := decoder.Decode(&data.Body); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
}
httpJSONError(w, err.Error())
return
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
// GetAllTags (method: GET) will get all tags currently in use
@@ -97,9 +96,7 @@ func RenameTag(w http.ResponseWriter, r *http.Request) {
// 200: OKResponse
// 400: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
tag := r.PathValue("tag")
decoder := json.NewDecoder(r.Body)
@@ -141,9 +138,7 @@ func DeleteTag(w http.ResponseWriter, r *http.Request) {
// 200: OKResponse
// 400: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
tag := r.PathValue("tag")
if err := storage.DeleteTag(tag); err != nil {
httpError(w, err.Error())

View File

@@ -11,7 +11,6 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/gorilla/mux"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
@@ -38,9 +37,8 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
id := strings.TrimSuffix(path, ".html")
if id == "latest" {
var err error
@@ -123,9 +121,8 @@ func GetMessageText(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
id := strings.TrimSuffix(path, ".txt")
if id == "latest" {
var err error

View File

@@ -12,7 +12,6 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime/v2"
"github.com/kovidgoyal/imaging"
)
@@ -42,10 +41,8 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
// 200: BinaryResponse
// 400: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
id := r.PathValue("id")
partID := r.PathValue("partID")
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
@@ -76,13 +73,14 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
var temp image.Image
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
temp = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
temp = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
dstImageFill := imaging.Clone(temp)
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)

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,51 +2,146 @@
package handlers
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
)
var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
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
)
// ProxyHandler is used to proxy assets for printing
var (
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
urlRe = regexp.MustCompile(`(?mU)url\(('|")?(https?:\/\/[^)'"]+)('|")?\)`)
assetsMutex sync.Mutex
assets = map[string]MessageAssets{}
)
// MessageAssets represents assets linked in a message
type MessageAssets struct {
ID string
// Created timestamp so we can expire old entries
Created time.Time
// Assets found in the message
Assets []string
}
func init() {
// Start a goroutine to clean up old asset entries every minute
go func() {
for {
time.Sleep(time.Minute)
assetsMutex.Lock()
now := time.Now()
for id, entry := range assets {
if now.Sub(entry.Created) > time.Minute {
logger.Log().Debugf("[proxy] cleaning up assets for message %s", id)
delete(assets, id)
}
}
assetsMutex.Unlock()
}
}()
}
// ProxyHandler is used to proxy assets for printing.
// It accepts a base64-encoded message-id:url string as the `data` query parameter.
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
uri := strings.TrimSpace(r.URL.Query().Get("url"))
if uri == "" {
logger.Log().Warn("[proxy] URL missing")
httpError(w, "Error: URL missing")
encoded := strings.TrimSpace(r.URL.Query().Get("data"))
if encoded == "" {
logger.Log().Warn("[proxy] Data missing")
httpError(w, "Error: Data missing")
return
}
if !linkRe.MatchString(uri) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
logger.Log().Warnf("[proxy] Data parameter corrupted: %s", err.Error())
httpError(w, "Error: invalid request")
return
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
logger.Log().Warnf("[proxy] Invalid data parameter: %s", string(decoded))
httpError(w, "Error: invalid request")
return
}
id := parts[0]
uri := parts[1]
links, err := getAssets(id)
if err != nil {
httpError(w, "Error: invalid request")
return
}
if !tools.InArray(uri, links) {
logger.Log().Warnf("[proxy] URL %s not found in message %s", uri, id)
httpError(w, "Error: invalid request")
return
}
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)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, err.Error())
httpError(w, "Error: invalid request")
return
}
@@ -56,23 +151,40 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
resp, err := client.Do(req)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, err.Error())
httpError(w, "Error: invalid request")
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logger.Log().Warnf("[proxy] received status code %d for %s", resp.StatusCode, uri)
httpError(w, "Error: invalid request")
return
}
ct := strings.ToLower(resp.Header.Get("content-type"))
if !supportedProxyContentType(ct) {
logger.Log().Warnf("[proxy] blocking unsupported content-type %s for %s", ct, uri)
httpError(w, "Error: invalid request")
return
}
limitedBody := io.LimitReader(resp.Body, maxProxyBodySize+1)
body, err := io.ReadAll(limitedBody)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, 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
if resp.Header.Get("content-type") != "" {
w.Header().Set("content-type", resp.Header.Get("content-type"))
}
w.Header().Set("content-type", ct)
if resp.Header.Get("last-modified") != "" {
w.Header().Set("last-modified", resp.Header.Get("last-modified"))
}
@@ -83,7 +195,7 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("cache-control", resp.Header.Get("cache-control"))
}
// replace url() values with proxy address, eg: fonts & images
// replace CSS url() values with proxy address, eg: fonts & images
if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") {
var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`)
body = re.ReplaceAllFunc(body, func(s []byte) []byte {
@@ -100,7 +212,20 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
return []byte(parts[3])
}
return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")")
// store asset address against message ID
assetsMutex.Lock()
if result, ok := assets[id]; ok {
if !tools.InArray(address, result.Assets) {
result.Assets = append(result.Assets, address)
assets[id] = result
}
}
assetsMutex.Unlock()
// encode with base64 to handle any special characters and group message ID with URL
encoded := base64.StdEncoding.EncodeToString([]byte(id + ":" + address))
return []byte("url(" + parts[2] + config.Webroot + "proxy?data=" + encoded + parts[4] + ")")
})
}
@@ -114,7 +239,82 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
}
}
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
// GetAssets retrieves and parses the message to return linked assets.
// Linked CSS files are appended to the assets list via the ProxyHandler when proxying CSS files.
func getAssets(id string) ([]string, error) {
assetsMutex.Lock()
defer assetsMutex.Unlock()
result, ok := assets[id]
if ok {
// return cached assets
return result.Assets, nil
}
msg, err := storage.GetMessage(id)
if err != nil {
return nil, err
}
links := []string{}
reader := strings.NewReader(msg.HTML)
// load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return nil, err
}
// css & font links
doc.Find("link").Each(func(_ int, s *goquery.Selection) {
if href, exists := s.Attr("href"); exists {
if linkRe.MatchString(href) && !tools.InArray(href, links) {
links = append(links, href)
}
}
})
// images
doc.Find("img").Each(func(_ int, s *goquery.Selection) {
if src, exists := s.Attr("src"); exists {
if linkRe.MatchString(src) && !tools.InArray(src, links) {
links = append(links, src)
}
}
})
// background="<>" links
doc.Find("[background]").Each(func(_ int, s *goquery.Selection) {
if bg, exists := s.Attr("background"); exists {
if linkRe.MatchString(bg) && !tools.InArray(bg, links) {
links = append(links, bg)
}
}
})
// url(<>) links in style blocks
matches := urlRe.FindAllStringSubmatch(msg.HTML, -1)
for _, match := range matches {
if len(match) >= 3 {
link := match[2]
if linkRe.MatchString(link) && !tools.InArray(link, links) {
links = append(links, link)
}
}
}
r := MessageAssets{}
r.ID = id
r.Created = time.Now()
r.Assets = links
assets[id] = r
return links, nil
}
// AbsoluteURL will return a full URL regardless whether it is relative or absolute.
// This is used to replace relative CSS url(...) links when proxying.
func absoluteURL(link, baseURL string) (string, error) {
// scheme relative links, eg <script src="//example.com/script.js">
if len(link) > 1 && link[0:2] == "//" {
@@ -156,3 +356,60 @@ func httpError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprint(w, msg)
}
// SupportedProxyContentType checks if the content-type is supported for proxying.
// This is limited to fonts, images and css only.
func supportedProxyContentType(ct string) bool {
ct = strings.ToLower(ct)
types := []string{
"font/otf",
"font/ttf",
"font/woff",
"font/woff2",
"image/apng",
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/tiff",
"image/svg+xml",
"image/webp",
"text/css",
}
for _, t := range types {
if strings.HasPrefix(ct, t) {
return true
}
}
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"
@@ -20,6 +21,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/shortuuid"
"github.com/axllent/mailpit/internal/snakeoil"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
@@ -27,26 +29,33 @@ import (
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
var (
// 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()
@@ -54,37 +63,43 @@ func Listen() {
r := apiRoutes()
// kubernetes probes
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
r.HandleFunc("GET "+config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc("GET "+config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler))
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"dist/", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"api/", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"favicon.ico", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"favicon.svg", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"mailpit.svg", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"notification.png", middleWareFunc(embedController))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redirect := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
r.HandleFunc("GET "+redirect, middleWareFunc(addSlashToWebroot))
}
// UI shortcut
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage))
// frontend testing
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET")
// frontend testing + web UI via virtual index.html
// Go's ServeMux wildcards must span a full path segment so {id}.html is invalid;
// viewHandler dispatches on the path suffix instead.
r.HandleFunc("GET "+config.Webroot+"view/", middleWareFunc(viewHandler))
// web UI via virtual index.html
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
r.Handle("GET "+config.Webroot+"search", middleWareFunc(index))
// Exact-match the webroot; stdlib "/" is always a subtree so we guard inside.
r.HandleFunc("GET "+config.Webroot, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != config.Webroot {
http.NotFound(w, r)
return
}
middleWareFunc(index)(w, r)
})
if auth.UICredentials != nil {
logger.Log().Info("[http] enabling basic authentication")
@@ -155,51 +170,51 @@ func Listen() {
}
}
func apiRoutes() *mux.Router {
r := mux.NewRouter()
func apiRoutes() *http.ServeMux {
r := http.NewServeMux()
// API V1
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages))
r.HandleFunc("PUT "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages))
r.HandleFunc("GET "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch))
r.HandleFunc("POST "+config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler))
r.HandleFunc("GET "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags))
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags))
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw))
r.HandleFunc("POST "+config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck))
if config.EnableSpamAssassin != "" {
r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck))
}
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage))
r.HandleFunc("GET "+config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo))
r.HandleFunc("GET "+config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig))
r.HandleFunc("GET "+config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath))
// Chaos
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
r.HandleFunc("GET "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos))
r.HandleFunc("PUT "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos))
// Prometheus metrics (if enabled and using existing server)
if prometheus.GetMode() == "integrated" {
r.HandleFunc(config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("GET "+config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
prometheus.GetHandler().ServeHTTP(w, r)
})).Methods("GET")
}))
}
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/events", middleWareFunc(apiWebsocket))
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
r.Handle("OPTIONS "+config.Webroot+"api/v1/", middleWareFunc(apiv1.GetOptions))
return r
}
@@ -208,25 +223,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 +253,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 +298,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 +328,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 +350,23 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// Websocket to broadcast changes
// viewHandler routes /view/ requests based on path suffix.
// Go's ServeMux requires wildcards to span a full path segment,
// so patterns like /view/{id}.html are invalid; we dispatch manually here.
func viewHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
switch {
case strings.HasSuffix(path, ".html"):
apiv1.GetMessageHTML(w, r)
case strings.HasSuffix(path, ".txt"):
apiv1.GetMessageText(w, r)
default:
index(w, r)
}
}
// Websocket to broadcast changes.
// Authentication and CORS are handled by middleWareFunc before this is reached.
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
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())
@@ -328,6 +328,53 @@ func TestAPIv1Send(t *testing.T) {
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
func TestAPIv1SendMaxMessageSize(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
original := config.MaxMessageSize
defer func() { config.MaxMessageSize = original }()
config.MaxMessageSize = 1 // 1 MiB cap for the test
bigText := strings.Repeat("X", 2*1024*1024)
oversized := fmt.Sprintf(`{
"From": {"Email": "a@example.com"},
"To": [{"Email": "b@example.com"}],
"Subject": "oversize",
"Text": %q
}`, bigText)
t.Log("Sending oversize message via HTTP API (expect 413)")
req, err := http.NewRequest("POST", ts.URL+"/api/v1/send", strings.NewReader(oversized))
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unexpected transport error: %s", err)
}
_ = resp.Body.Close()
assertEqual(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 413 for oversize body")
t.Log("Sending normal-sized message via HTTP API (expect 200)")
jsonData, _ := json.Marshal(testSendMessage)
if _, err := clientPost(ts.URL+"/api/v1/send", string(jsonData)); err != nil {
t.Errorf("expected success for in-bound payload, got: %s", err)
}
t.Log("Setting MaxMessageSize=0 (unlimited), oversize should now succeed")
config.MaxMessageSize = 0
if _, err := clientPost(ts.URL+"/api/v1/send", oversized); err != nil {
t.Errorf("expected success when MaxMessageSize=0, got: %s", err)
}
}
func TestSendAPIAuthMiddleware(t *testing.T) {
setup()
defer storage.Close()
@@ -545,11 +592,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 +801,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

@@ -75,16 +75,34 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
cursor: wait;
.loader-bar {
width: 35%;
height: 2px;
background: $success;
border-radius: 0 999px 999px 0;
animation: loader-slide 1.2s ease-in-out 200ms infinite backwards;
}
}
@keyframes loader-slide {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(110vw);
}
100% {
transform: translateX(110vw);
}
}
// dark mode adjustments
@include color-mode(dark) {
.loader {
background: rgba(0, 0, 0, 0.4);
}
.token.tag,
.token.property {
color: #ee6969;
@@ -314,6 +332,11 @@ body.blur {
display: none;
}
// dropdown doesn't always appear in correct position inside modals
.dropdown.form-select {
position: relative !important;
}
.message {
&.read {
> div {

View File

@@ -10,11 +10,7 @@ export default {
</script>
<template>
<div v-if="loading > 0" class="loader">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-muted" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-if="loading > 0" class="loader" role="status" aria-live="polite" aria-label="Loading">
<div class="loader-bar"></div>
</div>
</template>

View File

@@ -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>
@@ -124,8 +132,13 @@ export default {
mailbox.appInfo.LatestVersion
"
>
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
available.
<template v-if="isEdgeBuild || mailbox.appInfo.Version == 'dev'">
Latest stable Mailpit ({{ mailbox.appInfo.LatestVersion }}) release
</template>
<template v-else>
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
available
</template>
</a>
</div>
</div>

View File

@@ -53,5 +53,10 @@ export default {
navigator.setAppBadge(this.mailboxUnread);
},
},
render() {
// to remove webkit warnings about missing template or render function
return false;
},
};
</script>

View File

@@ -112,5 +112,10 @@ export default {
this.favicon.href = canvas.toDataURL("image/png");
},
},
render() {
// to remove webkit warnings about missing template or render function
return false;
},
};
</script>

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,6 +14,7 @@ export default {
timezones,
chaosConfig: false,
chaosUpdated: false,
defaultReleaseAddressesOptions: mailbox.defaultReleaseAddresses.slice(), // set with default release addresses
};
},
@@ -45,11 +46,13 @@ export default {
mounted() {
this.setTheme();
this.$nextTick(() => {
Tags.init("select.tz");
});
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
mailbox.skipConfirmations = localStorage.getItem("skip-confirmations");
window.setTimeout(() => {
Tags.init("select.tz");
Tags.init("select.default-release-addresses");
}, 500);
},
methods: {
@@ -98,7 +101,7 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
<ul id="myTab" class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button
id="ui-tab"
@@ -113,7 +116,25 @@ export default {
Web UI
</button>
</li>
<li class="nav-item" role="presentation">
<li
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
class="nav-item"
role="presentation"
>
<button
id="relay-tab"
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#relay-tab-pane"
type="button"
role="tab"
aria-controls="relay-tab-pane"
aria-selected="false"
>
Message release
</button>
</li>
<li v-if="mailbox.uiConfig.ChaosEnabled" class="nav-item" role="presentation">
<button
id="chaos-tab"
class="nav-link"
@@ -234,6 +255,50 @@ export default {
</div>
</div>
<!-- Default relay addresses -->
<div
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
id="relay-tab-pane"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="relay-tab"
tabindex="0"
>
<div class="my-3 mb-5">
<label class="form-label">Default release address(es)</label>
<div class="form-text mb-2">
You can designate the default "send to" addresses here, which will automatically
populate the field in the message release dialog. This setting applies only to your
browser. If this field is left empty, it will revert to the original recipients of
the message.
</div>
<select
v-model="mailbox.defaultReleaseAddresses"
class="form-select tag-selector default-release-addresses"
multiple
data-allow-new="true"
data-clear-end="true"
data-allow-clear="true"
data-placeholder="Enter email addresses..."
data-add-on-blur="true"
data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|"
>
<option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder -->
<option
v-for="t in defaultReleaseAddressesOptions"
:key="'address+' + t"
:value="t"
>
{{ t }}
</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div
v-if="mailbox.uiConfig.ChaosEnabled"
id="chaos-tab-pane"

View File

@@ -9,7 +9,7 @@ export default {
return {
mailbox,
editableTags: [],
validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
validTagRe: /^([a-zA-Z0-9\- ._@]){1,100}$/,
tagToDelete: false,
};
},
@@ -28,7 +28,7 @@ export default {
methods: {
validTag(t) {
if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
if (!t.after.match(/^([a-zA-Z0-9\- ._@]){1,100}$/)) {
return false;
}

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
});
@@ -216,7 +216,7 @@ export default {
resizeIframe(el) {
const i = el.target;
if (typeof i.contentWindow.document.body.scrollHeight === "number") {
if (i.contentWindow?.document?.body) {
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + "px";
}
},
@@ -226,10 +226,8 @@ export default {
return;
}
const h = document.getElementById("preview-html");
if (h) {
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
}
if (h?.contentWindow?.document?.body) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
}
},
@@ -288,9 +286,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 +321,10 @@ export default {
.replace(/˲˲˲/g, ">")
.replace(/ˠˠˠ/g, '"');
return html;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["a"],
ALLOWED_ATTR: ["href", "target", "rel"],
});
},
},
};
@@ -467,7 +471,7 @@ export default {
data-allow-clear="true"
data-placeholder="Add tags..."
data-badge-style="secondary"
data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
data-regex="^([a-zA-Z0-9\-\ \_\.@]){1,100}$"
data-separator="|,|"
>
<option value="">Type a tag...</option>
@@ -779,6 +783,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

@@ -44,7 +44,20 @@ export default {
// include only unique email addresses, regardless of casing
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));
this.addresses = this.allAddresses;
// include default release addresses from mailbox settings
const defaultAddr = mailbox.defaultReleaseAddresses;
for (const i in defaultAddr) {
if (!this.allAddresses.includes(defaultAddr[i])) {
this.allAddresses.push(defaultAddr[i]);
}
}
if (defaultAddr.length === 0) {
// prefill with all addresses if no default is set
this.addresses = this.allAddresses;
} else {
this.addresses = defaultAddr;
}
},
methods: {
@@ -140,6 +153,13 @@ export default {
<option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
<div class="form-text mt-1">
Default release addresses can be configured in
<a href="#" data-bs-toggle="modal" data-bs-target="#SettingsModal">
<i class="bi bi-gear-fill ms-1"></i>
Settings </a
>.
</div>
</div>
</div>
<div class="row mb-3">

Some files were not shown because too many files have changed in this diff Show More