mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-30 07:56:06 +00:00
Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8756a32c | ||
|
|
a9058f40db | ||
|
|
3b65ee936a | ||
|
|
bb81b62357 | ||
|
|
e27d30bda7 | ||
|
|
cae0f638af | ||
|
|
786f263d32 | ||
|
|
8041eac509 | ||
|
|
b7e4146dbf | ||
|
|
5ec074208c | ||
|
|
b82960928a | ||
|
|
4ab532b9aa | ||
|
|
35079d182c | ||
|
|
04c779994b | ||
|
|
bcd1bc71ee | ||
|
|
136bdde953 | ||
|
|
499a543963 | ||
|
|
8b4c9d1267 | ||
|
|
1cabac31ad | ||
|
|
da7b82378c | ||
|
|
0702241fa5 | ||
|
|
8d72191704 | ||
|
|
052afdf929 | ||
|
|
c1fbbffded | ||
|
|
6e2c42d2bc | ||
|
|
da8eb3ece8 | ||
|
|
4502cdc358 | ||
|
|
fbb63c89dd | ||
|
|
71bd44bbb5 | ||
|
|
b997fff7eb | ||
|
|
034a480a39 | ||
|
|
f575b53854 | ||
|
|
d469aac87c | ||
|
|
e4c3442e39 | ||
|
|
f11fc1ffe0 | ||
|
|
40c5936f79 | ||
|
|
8bc966e618 | ||
|
|
ec2a0851ab | ||
|
|
4bdbeebcc0 | ||
|
|
10430f7dce | ||
|
|
878c68bb49 | ||
|
|
86b0cf8557 | ||
|
|
123ec9a354 | ||
|
|
3b2423bdf1 | ||
|
|
6baf59cb06 | ||
|
|
f0777c7e63 | ||
|
|
91a4b81c80 | ||
|
|
943e3394f9 | ||
|
|
e84027d39e | ||
|
|
fe9c34f828 | ||
|
|
9ba51d0ab2 | ||
|
|
c4dbdc79b0 | ||
|
|
f7fdbb9df9 | ||
|
|
78d4503a9e | ||
|
|
f9b723aab5 | ||
|
|
a9fd3e9a07 | ||
|
|
780c27df44 | ||
|
|
5a2d59718f | ||
|
|
794077a836 | ||
|
|
4c6bf1b845 | ||
|
|
d05cbd76a5 | ||
|
|
061674eff4 | ||
|
|
d5ce8597ca | ||
|
|
13a63209a4 | ||
|
|
1937971233 | ||
|
|
294faa4f10 | ||
|
|
25b9ebd90e | ||
|
|
87472746a9 | ||
|
|
9dd1e99f52 | ||
|
|
fcca56625f | ||
|
|
3a4c7766e9 | ||
|
|
dc9b8d54b7 | ||
|
|
b8cc1bc415 | ||
|
|
0fee30d3df | ||
|
|
1200ad0506 | ||
|
|
c12c6458a3 | ||
|
|
16f0c1416d | ||
|
|
0e3441aba9 | ||
|
|
2dc2145db7 | ||
|
|
9c2359eee5 | ||
|
|
7b22d6a5f9 | ||
|
|
fcd964501a | ||
|
|
3a222dd147 | ||
|
|
857cf78984 | ||
|
|
6802e24e55 | ||
|
|
deaab34cdd | ||
|
|
ee9863289a | ||
|
|
70037e96f4 | ||
|
|
fc0b016549 | ||
|
|
140633718c | ||
|
|
f40911c580 | ||
|
|
3073ef9afe | ||
|
|
804d49b7ca | ||
|
|
7d29dff5e7 | ||
|
|
bc8a737d4f | ||
|
|
b99be839a0 | ||
|
|
c1db706677 | ||
|
|
ab3fc5ead7 | ||
|
|
a72d42c8d4 | ||
|
|
f8052e1d56 | ||
|
|
267bf8b639 | ||
|
|
51e327f259 | ||
|
|
bb6bdf629d | ||
|
|
a0a4ebb943 | ||
|
|
ba00ea5a21 | ||
|
|
2afc52c6fe | ||
|
|
5e9c522402 | ||
|
|
7bb330a07a | ||
|
|
ffb3067680 | ||
|
|
dc3e7e701f | ||
|
|
f1d0bcda90 | ||
|
|
4f651e4f14 | ||
|
|
c3819ca26d | ||
|
|
4febeb1acd | ||
|
|
10ad4df8cc | ||
|
|
632113fcc5 | ||
|
|
08ed46fc46 | ||
|
|
6927c2b73b | ||
|
|
ac81da5ae0 | ||
|
|
f1d55e4e39 | ||
|
|
b622252411 | ||
|
|
5527379475 | ||
|
|
1d87f1164e | ||
|
|
b4ca68eb48 | ||
|
|
971ae95a67 | ||
|
|
c8caa29e24 | ||
|
|
7d314d2b50 | ||
|
|
9d2f30787a | ||
|
|
b9d071db81 | ||
|
|
a5ee550ba3 | ||
|
|
3e41beb214 | ||
|
|
43b8ba3dc6 | ||
|
|
d41eca3df7 | ||
|
|
e6fd638067 | ||
|
|
e2b1b2d0fe | ||
|
|
9b4ec97483 | ||
|
|
e735904167 | ||
|
|
94113222cc | ||
|
|
5414695508 | ||
|
|
dd74d46880 | ||
|
|
0bfbb4cc5f | ||
|
|
38c0c4fd47 | ||
|
|
9391b075d0 | ||
|
|
a87b2a9455 | ||
|
|
8d18618e4a | ||
|
|
a63bcd9bd3 | ||
|
|
f33f9bec2d | ||
|
|
ff47ba96b8 | ||
|
|
b9f36312d7 | ||
|
|
291c449591 | ||
|
|
d7a4a60536 | ||
|
|
464ff68c34 | ||
|
|
9383c5876b | ||
|
|
a3616e52d9 | ||
|
|
980e54c21f | ||
|
|
eac491cd89 | ||
|
|
12076bca72 | ||
|
|
028ca1d715 | ||
|
|
7d7ba88e9c | ||
|
|
973fc1f975 | ||
|
|
1679a0aba5 | ||
|
|
4a4c149eed | ||
|
|
c01335f0e3 | ||
|
|
181cb0714a | ||
|
|
00d52d5931 | ||
|
|
050da038af | ||
|
|
36cc06c125 | ||
|
|
2734efbc66 | ||
|
|
7cda4a36f1 | ||
|
|
45b3676e52 | ||
|
|
d50347d667 | ||
|
|
c035139b54 | ||
|
|
3108d82e06 | ||
|
|
648d5863da | ||
|
|
585ea1dc30 | ||
|
|
c66a06379a | ||
|
|
c5c9292863 | ||
|
|
6f1f4f34c9 | ||
|
|
877a9159ce | ||
|
|
c4582889ad | ||
|
|
cd1cf695b9 | ||
|
|
392904fd23 | ||
|
|
f0160c0e29 | ||
|
|
f9024d1f77 | ||
|
|
061f159293 | ||
|
|
e69a0d75c9 | ||
|
|
0847167694 | ||
|
|
6dd3587ec6 | ||
|
|
2d1e38d4fd | ||
|
|
153174f928 | ||
|
|
3b9b470c09 | ||
|
|
dd99a4bcf0 | ||
|
|
5bf2f2796b | ||
|
|
a469655f65 | ||
|
|
432fedeafa | ||
|
|
37e4ff4139 | ||
|
|
2808316dd2 | ||
|
|
43d759b0fc | ||
|
|
264222d599 | ||
|
|
5e4bdb78b8 | ||
|
|
fc9572156b | ||
|
|
d52a0d550f | ||
|
|
fcce621f18 | ||
|
|
f4cd19aac2 | ||
|
|
46ccf866b2 | ||
|
|
266611fda0 | ||
|
|
fe3920e3c6 | ||
|
|
ac02802d62 | ||
|
|
7d6aab4e01 | ||
|
|
36d8525557 | ||
|
|
0f0a5d942f | ||
|
|
b987006897 | ||
|
|
c8e0bee8bb | ||
|
|
3b0ae24c2a | ||
|
|
aca491f10c | ||
|
|
6724f0ccdd | ||
|
|
93088f3361 | ||
|
|
e817bf5f7d | ||
|
|
4d100a9ec3 | ||
|
|
958fa6cf1a | ||
|
|
27e12474f5 | ||
|
|
302b269fb6 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [axllent]
|
||||
thanks_dev: u/gh/axllent
|
||||
|
||||
10
.github/SECURITY.md
vendored
10
.github/SECURITY.md
vendored
@@ -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
1
.github/cliff.toml
vendored
@@ -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"},
|
||||
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -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"
|
||||
|
||||
32
.github/workflows/build-docker-edge.yml
vendored
32
.github/workflows/build-docker-edge.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/build-docker.yml
vendored
12
.github/workflows/build-docker.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
3
.github/workflows/close-stale-issues.yml
vendored
3
.github/workflows/close-stale-issues.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests-rqlite.yml
vendored
2
.github/workflows/tests-rqlite.yml
vendored
@@ -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:
|
||||
|
||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
273
CHANGELOG.md
273
CHANGELOG.md
@@ -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]
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
13
README.md
13
README.md
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
19
cmd/root.go
19
cmd/root.go
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
79
go.mod
@@ -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
188
go.sum
@@ -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=
|
||||
|
||||
58
install.sh
58
install.sh
@@ -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
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -18,9 +20,24 @@ import (
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
)
|
||||
|
||||
// httpClient bounds each remote request so a slow or hostile --http endpoint
|
||||
// cannot hang the dump indefinitely.
|
||||
var httpClient = &http.Client{Timeout: time.Minute}
|
||||
|
||||
// maxSummarySize caps the bytes read from the remote messages-summary endpoint
|
||||
// to prevent a hostile server from exhausting memory via an unbounded response.
|
||||
const maxSummarySize = 20 * 1024 * 1024 // 20 MiB
|
||||
|
||||
// pageSize is the per-request limit when paging through the remote messages
|
||||
// summary endpoint.
|
||||
const pageSize = 10000
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
// idRe matches a valid Mailpit message ID (alphanumeric or dash, 8–60 chars).
|
||||
idRe = regexp.MustCompile(`^[a-zA-Z0-9-]{8,60}$`)
|
||||
|
||||
outDir string
|
||||
|
||||
// Base URL of mailpit instance
|
||||
@@ -29,13 +46,15 @@ var (
|
||||
// URL is the base URL of a remove Mailpit instance
|
||||
URL string
|
||||
|
||||
summary = []storage.MessageSummary{}
|
||||
dumpIDs = make(map[string]struct {
|
||||
Timestamp time.Time
|
||||
})
|
||||
)
|
||||
|
||||
// Sync will sync all messages from the specified database or API to the specified output directory
|
||||
func Sync(d string) error {
|
||||
|
||||
outDir = path.Clean(d)
|
||||
outDir = filepath.Clean(d)
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
@@ -71,53 +90,117 @@ func loadIDs() error {
|
||||
if base != "" {
|
||||
// remote
|
||||
logger.Log().Debugf("Fetching messages summary from %s", base)
|
||||
res, err := http.Get(base + "api/v1/messages?limit=0")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
start := 0
|
||||
var total uint64
|
||||
for {
|
||||
data, err := fetchSummaryPage(start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if start == 0 {
|
||||
total = data.Total
|
||||
}
|
||||
|
||||
for _, m := range data.Messages {
|
||||
dumpIDs[m.ID] = struct {
|
||||
Timestamp time.Time
|
||||
}{Timestamp: m.Created}
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetched messages summary page start=%d size=%d (%d/%d)", start, len(data.Messages), len(dumpIDs), total)
|
||||
|
||||
// stop on empty page to guard against stale/inconsistent Total
|
||||
if len(data.Messages) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if uint64(len(dumpIDs)) >= total {
|
||||
break
|
||||
}
|
||||
|
||||
start += pageSize
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary = data.Messages
|
||||
|
||||
} else {
|
||||
// make sure the database isn't pruned while open
|
||||
config.MaxMessages = 0
|
||||
|
||||
var err error
|
||||
// local database
|
||||
if err = storage.InitDB(); err != nil {
|
||||
if err := storage.InitDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
|
||||
|
||||
summary, err = storage.List(0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
start := 0
|
||||
for {
|
||||
page, err := storage.List(start, 0, pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range page {
|
||||
dumpIDs[m.ID] = struct {
|
||||
Timestamp time.Time
|
||||
}{Timestamp: m.Created}
|
||||
}
|
||||
|
||||
if len(page) < pageSize {
|
||||
break
|
||||
}
|
||||
|
||||
start += pageSize
|
||||
}
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
if len(dumpIDs) == 0 {
|
||||
return errors.New("no messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSummaryPage fetches a single page of the remote messages summary,
|
||||
// starting at the given offset.
|
||||
func fetchSummaryPage(start int) (*apiv1.MessagesSummary, error) {
|
||||
url := base + "api/v1/messages?limit=" + strconv.Itoa(pageSize) + "&start=" + strconv.Itoa(start)
|
||||
res, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("error fetching messages summary: HTTP " + res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, maxSummarySize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(len(body)) > maxSummarySize {
|
||||
return nil, errors.New("messages summary exceeds size cap")
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func saveMessages() error {
|
||||
for _, m := range summary {
|
||||
out := path.Join(outDir, m.ID+".eml")
|
||||
for id, m := range dumpIDs {
|
||||
if !idRe.MatchString(id) {
|
||||
logger.Log().Errorf("skipping message with invalid ID: %q", id)
|
||||
continue
|
||||
}
|
||||
|
||||
out := filepath.Join(outDir, id+".eml")
|
||||
|
||||
// skip if message exists
|
||||
if tools.IsFile(out) {
|
||||
@@ -126,37 +209,66 @@ func saveMessages() error {
|
||||
|
||||
var b []byte
|
||||
|
||||
limit := int64(config.MaxMessageSize) * 1024 * 1024
|
||||
|
||||
if base != "" {
|
||||
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
|
||||
res, err := httpClient.Get(base + "api/v1/message/" + id + "/raw")
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
b, err = io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
if res.StatusCode != http.StatusOK {
|
||||
res.Body.Close()
|
||||
logger.Log().Errorf("error fetching message %s: HTTP %d", id, res.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 {
|
||||
b, err = io.ReadAll(io.LimitReader(res.Body, limit+1))
|
||||
res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if int64(len(b)) > limit {
|
||||
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
b, err = io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
b, err = storage.GetMessageRaw(m.ID)
|
||||
b, err = storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error fetching message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 && int64(len(b)) > limit {
|
||||
logger.Log().Warnf("message %s exceeds %d MiB size cap, skipping", id, config.MaxMessageSize)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
|
||||
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
|
||||
logger.Log().Errorf("error writing message %s: %s", id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.Chtimes(out, m.Created, m.Created)
|
||||
_ = os.Chtimes(out, m.Timestamp, m.Timestamp)
|
||||
|
||||
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
|
||||
logger.Log().Debugf("Saved message %s to %s", id, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,7 +3,7 @@ package html2text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -30,18 +30,18 @@ func init() {
|
||||
}
|
||||
|
||||
// Strip will convert a HTML string to plain text
|
||||
func Strip(h string, includeLinks bool) string {
|
||||
func Strip(h string, includeLinks bool) (string, error) {
|
||||
h = spaceRe.ReplaceAllString(h, "</$1> <")
|
||||
h = brRe.ReplaceAllString(h, " ")
|
||||
h = imgRe.ReplaceAllString(h, " <$1")
|
||||
var buffer bytes.Buffer
|
||||
doc, err := html.Parse(strings.NewReader(h))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return "", fmt.Errorf("html2text: parsing HTML: %w", err)
|
||||
}
|
||||
|
||||
extract(doc, &buffer, includeLinks)
|
||||
return clean(buffer.String())
|
||||
return clean(buffer.String()), nil
|
||||
}
|
||||
|
||||
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
@@ -52,7 +52,8 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
}
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
if _, skip := skip[c.Data]; !skip {
|
||||
if _, shouldSkip := skip[c.Data]; !shouldSkip {
|
||||
extract(c, buff, includeLinks)
|
||||
if includeLinks && c.Data == "a" {
|
||||
for _, a := range c.Attr {
|
||||
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
|
||||
@@ -60,7 +61,6 @@ func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
extract(c, buff, includeLinks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestPlain(t *testing.T) {
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, false)
|
||||
res, _ := Strip(str, false)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
@@ -42,12 +42,12 @@ func TestWithLinks(t *testing.T) {
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1>
|
||||
<p>Paragraph</p>`] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text https://github.com"
|
||||
// broken html
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text. https://github.com"
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, true)
|
||||
res, _ := Strip(str, true)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"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 {}}` doesn’t 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 {}}` doesn’t 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 <option>.",
|
||||
"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 `	`"}
|
||||
},
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -2,138 +2,171 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Registry is the Prometheus registry for Mailpit metrics
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Metrics
|
||||
totalMessages prometheus.Gauge
|
||||
unreadMessages prometheus.Gauge
|
||||
databaseSize prometheus.Gauge
|
||||
messagesDeleted prometheus.Counter
|
||||
smtpAccepted prometheus.Counter
|
||||
smtpRejected prometheus.Counter
|
||||
smtpIgnored prometheus.Counter
|
||||
smtpAcceptedSize prometheus.Counter
|
||||
uptime prometheus.Gauge
|
||||
memoryUsage prometheus.Gauge
|
||||
tagCounters *prometheus.GaugeVec
|
||||
)
|
||||
|
||||
// InitMetrics initializes all Prometheus metrics
|
||||
func initMetrics() {
|
||||
// Create metrics
|
||||
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages",
|
||||
Help: "Total number of messages in the database",
|
||||
})
|
||||
|
||||
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages_unread",
|
||||
Help: "Number of unread messages in the database",
|
||||
})
|
||||
|
||||
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_database_size_bytes",
|
||||
Help: "Size of the database in bytes",
|
||||
})
|
||||
|
||||
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_messages_deleted_total",
|
||||
Help: "Total number of messages deleted",
|
||||
})
|
||||
|
||||
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_total",
|
||||
Help: "Total number of SMTP messages accepted",
|
||||
})
|
||||
|
||||
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_rejected_total",
|
||||
Help: "Total number of SMTP messages rejected",
|
||||
})
|
||||
|
||||
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_ignored_total",
|
||||
Help: "Total number of SMTP messages ignored (duplicates)",
|
||||
})
|
||||
|
||||
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_size_bytes_total",
|
||||
Help: "Total size of accepted SMTP messages in bytes",
|
||||
})
|
||||
|
||||
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_uptime_seconds",
|
||||
Help: "Uptime of Mailpit in seconds",
|
||||
})
|
||||
|
||||
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_memory_usage_bytes",
|
||||
Help: "Memory usage in bytes",
|
||||
})
|
||||
|
||||
tagCounters = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "mailpit_tag_messages",
|
||||
Help: "Number of messages per tag",
|
||||
},
|
||||
[]string{"tag"},
|
||||
)
|
||||
|
||||
// Register metrics
|
||||
Registry.MustRegister(totalMessages)
|
||||
Registry.MustRegister(unreadMessages)
|
||||
Registry.MustRegister(databaseSize)
|
||||
Registry.MustRegister(messagesDeleted)
|
||||
Registry.MustRegister(smtpAccepted)
|
||||
Registry.MustRegister(smtpRejected)
|
||||
Registry.MustRegister(smtpIgnored)
|
||||
Registry.MustRegister(smtpAcceptedSize)
|
||||
Registry.MustRegister(uptime)
|
||||
Registry.MustRegister(memoryUsage)
|
||||
Registry.MustRegister(tagCounters)
|
||||
type gauge struct {
|
||||
mu sync.RWMutex
|
||||
val float64
|
||||
}
|
||||
|
||||
func (g *gauge) Set(v float64) {
|
||||
g.mu.Lock()
|
||||
g.val = v
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *gauge) get() float64 {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.val
|
||||
}
|
||||
|
||||
type gaugeVec struct {
|
||||
mu sync.RWMutex
|
||||
label string
|
||||
vals map[string]float64
|
||||
}
|
||||
|
||||
func newGaugeVec(label string) *gaugeVec {
|
||||
return &gaugeVec{label: label, vals: make(map[string]float64)}
|
||||
}
|
||||
|
||||
func (v *gaugeVec) Set(labelVal string, val float64) {
|
||||
v.mu.Lock()
|
||||
v.vals[labelVal] = val
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
func (v *gaugeVec) Reset() {
|
||||
v.mu.Lock()
|
||||
v.vals = make(map[string]float64)
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
help string
|
||||
typ string
|
||||
g *gauge
|
||||
vec *gaugeVec
|
||||
}
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
registry []entry
|
||||
|
||||
totalMessages = &gauge{}
|
||||
unreadMessages = &gauge{}
|
||||
databaseSize = &gauge{}
|
||||
messagesDeleted = &gauge{}
|
||||
smtpAccepted = &gauge{}
|
||||
smtpRejected = &gauge{}
|
||||
smtpIgnored = &gauge{}
|
||||
smtpAcceptedSize = &gauge{}
|
||||
uptime = &gauge{}
|
||||
memoryUsage = &gauge{}
|
||||
tagCounters = newGaugeVec("tag")
|
||||
)
|
||||
|
||||
func register(name, help, typ string, g *gauge, vec *gaugeVec) {
|
||||
regMu.Lock()
|
||||
registry = append(registry, entry{name: name, help: help, typ: typ, g: g, vec: vec})
|
||||
regMu.Unlock()
|
||||
}
|
||||
|
||||
func initMetrics() {
|
||||
register("mailpit_database_size_bytes", "Size of the database in bytes", "gauge", databaseSize, nil)
|
||||
register("mailpit_memory_usage_bytes", "Memory usage in bytes", "gauge", memoryUsage, nil)
|
||||
register("mailpit_messages", "Total number of messages in the database", "gauge", totalMessages, nil)
|
||||
register("mailpit_messages_deleted_total", "Total number of messages deleted", "counter", messagesDeleted, nil)
|
||||
register("mailpit_messages_unread", "Number of unread messages in the database", "gauge", unreadMessages, nil)
|
||||
register("mailpit_smtp_accepted_size_bytes_total", "Total size of accepted SMTP messages in bytes", "counter", smtpAcceptedSize, nil)
|
||||
register("mailpit_smtp_accepted_total", "Total number of SMTP messages accepted", "counter", smtpAccepted, nil)
|
||||
register("mailpit_smtp_ignored_total", "Total number of SMTP messages ignored (duplicates)", "counter", smtpIgnored, nil)
|
||||
register("mailpit_smtp_rejected_total", "Total number of SMTP messages rejected", "counter", smtpRejected, nil)
|
||||
register("mailpit_tag_messages", "Number of messages per tag", "gauge", nil, tagCounters)
|
||||
register("mailpit_uptime_seconds", "Uptime of Mailpit in seconds", "gauge", uptime, nil)
|
||||
}
|
||||
|
||||
// UpdateMetrics updates all metrics with current values
|
||||
func updateMetrics() {
|
||||
info := stats.Load(false)
|
||||
|
||||
totalMessages.Set(float64(info.Messages))
|
||||
unreadMessages.Set(float64(info.Unread))
|
||||
databaseSize.Set(float64(info.DatabaseSize))
|
||||
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
messagesDeleted.Set(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Set(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Set(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Set(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Set(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
uptime.Set(float64(info.RuntimeStats.Uptime))
|
||||
memoryUsage.Set(float64(info.RuntimeStats.Memory))
|
||||
|
||||
// Reset tag counters
|
||||
tagCounters.Reset()
|
||||
|
||||
// Update tag counters
|
||||
for tag, count := range info.Tags {
|
||||
tagCounters.WithLabelValues(tag).Set(float64(count))
|
||||
tagCounters.Set(tag, float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus handler & disables double compression in middleware
|
||||
func writeMetrics(w io.Writer) {
|
||||
regMu.RLock()
|
||||
entries := make([]entry, len(registry))
|
||||
copy(entries, registry)
|
||||
regMu.RUnlock()
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].name < entries[j].name
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n", e.name, e.help, e.name, e.typ)
|
||||
if e.g != nil {
|
||||
fmt.Fprintf(w, "%s %s\n", e.name, formatFloat(e.g.get()))
|
||||
} else {
|
||||
e.vec.mu.RLock()
|
||||
keys := make([]string, 0, len(e.vec.vals))
|
||||
snapshot := make(map[string]float64, len(e.vec.vals))
|
||||
for k, v := range e.vec.vals {
|
||||
keys = append(keys, k)
|
||||
snapshot[k] = v
|
||||
}
|
||||
e.vec.mu.RUnlock()
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, "%s{%s=\"%s\"} %s\n", e.name, e.vec.label, escapeLabelValue(k), formatFloat(snapshot[k]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func escapeLabelValue(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus metrics HTTP handler
|
||||
func GetHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
writeMetrics(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,11 +175,9 @@ func StartUpdater() {
|
||||
initMetrics()
|
||||
updateMetrics()
|
||||
|
||||
// Start periodic updates
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
updateMetrics()
|
||||
}
|
||||
@@ -159,18 +190,15 @@ func StartSeparateServer() {
|
||||
|
||||
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
|
||||
|
||||
// Create a dedicated mux for the metrics server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||
mux.Handle("/metrics", GetHandler())
|
||||
|
||||
// Create a dedicated server instance
|
||||
server := &http.Server{
|
||||
Addr: config.PrometheusListen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
|
||||
}
|
||||
@@ -179,7 +207,6 @@ func StartSeparateServer() {
|
||||
// GetMode returns the Prometheus run mode
|
||||
func GetMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
|
||||
|
||||
switch mode {
|
||||
case "false", "":
|
||||
return "disabled"
|
||||
|
||||
53
internal/shortuuid/shortuuid.go
Normal file
53
internal/shortuuid/shortuuid.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package shortuuid provides a simple way to generate short, unique, alphanumeric identifiers.
|
||||
// The generated IDs are 22 characters long and consist of uppercase letters, lowercase letters, and digits.
|
||||
package shortuuid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/bits"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
length = 22
|
||||
nDigits = 10
|
||||
divisor = 839299365868340224 // 62^10, max power of 62 that fits in uint64
|
||||
)
|
||||
|
||||
// New returns a 22-character alphanumeric unique identifier.
|
||||
func New() string {
|
||||
id := uuid.New()
|
||||
num := [2]uint64{
|
||||
binary.BigEndian.Uint64(id[8:]),
|
||||
binary.BigEndian.Uint64(id[:8]),
|
||||
}
|
||||
|
||||
buf := make([]byte, length)
|
||||
var r uint64
|
||||
i := length - 1
|
||||
for num[1] > 0 || num[0] > 0 {
|
||||
num, r = quoRem64(num, divisor)
|
||||
for j := 0; j < nDigits && i >= 0; j++ {
|
||||
buf[i] = alphabet[r%62]
|
||||
r /= 62
|
||||
i--
|
||||
}
|
||||
}
|
||||
for ; i >= 0; i-- {
|
||||
buf[i] = alphabet[0]
|
||||
}
|
||||
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// quoRem64 divides a 128-bit number (represented as [lo, hi] uint64) by v,
|
||||
// returning the quotient and remainder.
|
||||
func quoRem64(u [2]uint64, v uint64) ([2]uint64, uint64) {
|
||||
var q [2]uint64
|
||||
var r uint64
|
||||
q[1], r = bits.Div64(0, u[1], v)
|
||||
q[0], r = bits.Div64(r, u[0], v)
|
||||
return q, r
|
||||
}
|
||||
52
internal/shortuuid/shortuuid_test.go
Normal file
52
internal/shortuuid/shortuuid_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package shortuuid
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// alphanumeric matches IDs that contain only digits and ASCII letters.
|
||||
var alphanumeric = regexp.MustCompile(`^[0-9A-Za-z]+$`)
|
||||
|
||||
// TestLength verifies that every generated ID is exactly 22 characters long,
|
||||
// including when the UUID encodes to a value with leading zero-padding.
|
||||
func TestLength(t *testing.T) {
|
||||
for range 100 {
|
||||
id := New()
|
||||
if len(id) != length {
|
||||
t.Errorf("expected length %d, got %d: %q", length, len(id), id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphanumeric verifies that no ID contains hyphens, underscores, or any
|
||||
// other non-alphanumeric character that would be unsafe in a URL path segment.
|
||||
func TestAlphanumeric(t *testing.T) {
|
||||
for range 100 {
|
||||
id := New()
|
||||
if !alphanumeric.MatchString(id) {
|
||||
t.Errorf("non-alphanumeric characters in ID: %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnique verifies that IDs are unique across a large sample. Collisions are
|
||||
// cryptographically implausible given the 122-bit UUID entropy, so any hit here
|
||||
// indicates a bug in the encoding (e.g. truncation, constant output).
|
||||
func TestUnique(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 1000000)
|
||||
for range 1000000 {
|
||||
id := New()
|
||||
if _, exists := seen[id]; exists {
|
||||
t.Fatalf("duplicate ID generated: %q", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNew measures the cost of generating a single ID, including UUID generation.
|
||||
func BenchmarkNew(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = New()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(®ularAttachment, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal file
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal 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--
|
||||
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal file
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal 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--
|
||||
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal file
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal 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--
|
||||
@@ -57,7 +57,7 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
b.WriteString(env.GetHeader("Reply-To") + " ")
|
||||
b.WriteString(env.GetHeader("Return-Path") + " ")
|
||||
|
||||
h := html2text.Strip(env.HTML, true)
|
||||
h, _ := html2text.Strip(env.HTML, true)
|
||||
if h != "" {
|
||||
b.WriteString(h + " ")
|
||||
} else {
|
||||
|
||||
@@ -2,20 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
// IsFile returns whether a path exists and is a regular file.
|
||||
// Symlinks are deliberately rejected to prevent following links to
|
||||
// arbitrary files outside the intended location.
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func IsDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
39
internal/tools/net.go
Normal file
39
internal/tools/net.go
Normal 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() != ""
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
2571
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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": {
|
||||
|
||||
@@ -23,10 +23,12 @@ import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"net"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/mneis/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -119,30 +121,49 @@ func Run() {
|
||||
socketAddr, isSocket := socketAddress(SMTPAddr)
|
||||
|
||||
// handles `sendmail -bs`
|
||||
// telnet directly to SMTP
|
||||
// relay stdin/stdout to SMTP connection
|
||||
if UseB && UseS {
|
||||
var caller = telnet.StandardCaller
|
||||
switch isSocket {
|
||||
case true:
|
||||
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
network := "tcp"
|
||||
addr := SMTPAddr
|
||||
if isSocket {
|
||||
network = "unix"
|
||||
addr = socketAddr
|
||||
}
|
||||
|
||||
conn, err := net.Dial(network, addr)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(os.Stdout, conn)
|
||||
close(done)
|
||||
}()
|
||||
_, _ = io.Copy(conn, os.Stdin)
|
||||
if cw, ok := conn.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
const maxMessageSize = 1000 * 1024 * 1024
|
||||
body, err := io.ReadAll(io.LimitReader(os.Stdin, maxMessageSize+1))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading stdin")
|
||||
os.Exit(11)
|
||||
}
|
||||
if len(body) > maxMessageSize {
|
||||
fmt.Fprintf(os.Stderr, "message exceeds %d MiB size cap\n", maxMessageSize/(1024*1024))
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.
|
||||
@@ -29,6 +27,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
return fmt.Errorf("no To addresses specified")
|
||||
}
|
||||
|
||||
if err := validateLine(fromAddress.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range to {
|
||||
if err := validateLine(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !isSocket {
|
||||
return sendMail(addr, nil, fromAddress.Address, to, msg)
|
||||
}
|
||||
@@ -40,16 +47,15 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
|
||||
client, err := smtp.NewClient(conn, "")
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Set the sender
|
||||
if err := client.Mail(fromAddress.Address); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
return fmt.Errorf("error setting sender: %w", err)
|
||||
}
|
||||
|
||||
// Set the recipient
|
||||
for _, a := range to {
|
||||
if err := client.Rcpt(a); err != nil {
|
||||
return err
|
||||
@@ -61,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
@@ -35,8 +34,7 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -105,8 +103,7 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -158,8 +155,7 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
|
||||
@@ -10,11 +10,10 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/shortuuid"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
@@ -45,9 +44,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
id := r.PathValue("id")
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -42,11 +43,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if config.MaxMessageSize > 0 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := sendMessageParams{}
|
||||
|
||||
if err := decoder.Decode(&data.Body); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
httpJSONError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetAllTags (method: GET) will get all tags currently in use
|
||||
@@ -97,9 +96,7 @@ func RenameTag(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
tag := vars["tag"]
|
||||
tag := r.PathValue("tag")
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -141,9 +138,7 @@ func DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
tag := vars["tag"]
|
||||
tag := r.PathValue("tag")
|
||||
|
||||
if err := storage.DeleteTag(tag); err != nil {
|
||||
httpError(w, err.Error())
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
@@ -38,9 +37,8 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
|
||||
id := strings.TrimSuffix(path, ".html")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
@@ -123,9 +121,8 @@ func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
|
||||
id := strings.TrimSuffix(path, ".txt")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -42,10 +41,8 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: BinaryResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
id := r.PathValue("id")
|
||||
partID := r.PathValue("partID")
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
@@ -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
135
server/cors.go
Normal 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
122
server/cors_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
189
server/server.go
189
server/server.go
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,5 +53,10 @@ export default {
|
||||
navigator.setAppBadge(this.mailboxUnread);
|
||||
},
|
||||
},
|
||||
|
||||
render() {
|
||||
// to remove webkit warnings about missing template or render function
|
||||
return false;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user