Compare commits

..

109 Commits

Author SHA1 Message Date
Ralph Slooten
72e92d2d1e Merge branch 'release/v1.26.0' 2025-06-06 19:05:40 +12:00
Ralph Slooten
803adf29ac Release v1.26.0 2025-06-06 19:05:39 +12:00
Ralph Slooten
fb0230a460 Chore: Update node dependencies 2025-06-06 19:01:47 +12:00
Ralph Slooten
873193bcec Chore: Update Go dependencies 2025-06-06 18:59:31 +12:00
Ralph Slooten
e3538cb86a Improve Prometheus GetMode detection 2025-06-06 17:43:20 +12:00
Ralph Slooten
e6ab9e1008 Fix: Fix sendmail symlink detection for macOS (#514) 2025-06-06 17:38:53 +12:00
Ralph Slooten
86f3546bfe Update Prometheus metrics flag description to use 'ip:port' format
Reorder Prometheus flag
2025-06-06 16:11:29 +12:00
Ralph Slooten
a6b5f5f76b Refactor Prometheus metrics configuration and validation 2025-06-06 15:34:06 +12:00
Ben Edmunds
82d7bdc971 Feature: Add Prometheus exporter (#505) 2025-06-06 14:33:49 +12:00
Ralph Slooten
020d5b0fcb Merge branch 'feature/send-auth' into develop 2025-06-02 14:52:43 +12:00
Ralph Slooten
f2b91ac9d5 Chore: Add MP_DATA_FILE deprecation warning 2025-05-30 11:04:20 +12:00
Ralph Slooten
4dff7adc1d Reorder send API CLI flags 2025-05-30 11:03:30 +12:00
Ben Edmunds
9bfdeb5f7b Feature: Send API allow separate auth (#504)
Co-authored-by: Ben Edmunds <ben.edmunds@dotdigital.com>
2025-05-30 08:34:40 +12:00
Ralph Slooten
c5b3edf87d Fix: Ignore basic auth for OPTIONS requests to API when CORS is set
Web browsers do not send authorization headers for  preflight requests.
2025-05-30 00:00:05 +12:00
Ralph Slooten
8c59229f97 Merge tag 'v1.25.1' into develop
Release v1.25.1
2025-05-25 10:12:14 +12:00
Ralph Slooten
56739ceac2 Merge branch 'release/v1.25.1' 2025-05-25 10:12:06 +12:00
Ralph Slooten
5240b1b33e Release v1.25.1 2025-05-25 10:12:05 +12:00
Ralph Slooten
8f80a57c3c Chore: Update node dependencies 2025-05-25 10:10:08 +12:00
Ralph Slooten
04ea905619 Chore: Update Go dependencies 2025-05-25 10:01:06 +12:00
Ralph Slooten
b84b428434 Chore: Add note to swagger docs about API date formats 2025-05-25 09:56:53 +12:00
Ralph Slooten
91409310d7 Chore: Lighten outline-secondary buttons in dark mode 2025-05-23 23:19:54 +12:00
Ralph Slooten
99a3e17243 Fix: Update bootstrap5-tags to fix text pasting in message release modal (#498) 2025-05-23 22:37:06 +12:00
Ralph Slooten
ff272d1c5e Chore: Extend latest version cache expiration from 5 to 15 minutes 2025-05-20 16:55:37 +12:00
Ralph Slooten
74c6a0a434 Chore: Switch from unnecessary float64 to uint64 API values for App Information, message & attachment sizes 2025-05-20 16:51:02 +12:00
Ralph Slooten
e16267ab50 Merge tag 'v1.25.0' into develop
Release v1.25.0
2025-05-18 11:17:31 +12:00
Ralph Slooten
8d86b39385 Merge branch 'release/v1.25.0' 2025-05-18 11:17:22 +12:00
Ralph Slooten
38914348a5 Release v1.25.0 2025-05-18 11:17:20 +12:00
Ralph Slooten
25580b9a68 Chore: Update caniemail database 2025-05-18 10:48:05 +12:00
Ralph Slooten
a1c2690c44 Use text-muted instead of text-secondary 2025-05-18 10:31:39 +12:00
Ralph Slooten
ff8b6326ab Chore: Update node dependencies 2025-05-18 10:31:38 +12:00
Ralph Slooten
5d2966d726 Chore: Update Go dependencies 2025-05-18 10:31:37 +12:00
Ralph Slooten
bf5609a39b Chore: Adjust UI margin for side navigation 2025-05-18 10:31:36 +12:00
Ralph Slooten
4ed5011a8f Chore: Tweak UI to improve contrast between read & unread messages 2025-05-18 10:31:28 +12:00
Ralph Slooten
68d911431f Chore: Switch yaml parser to github.com/goccy/go-yaml
The package gopkg.in/yaml.v3 is now no longer maintained, see https://github.com/go-yaml/yaml
2025-05-17 22:40:11 +12:00
Ralph Slooten
d0716b4995 Feature: Add option to hide the "Delete all" button in web UI (#495) 2025-05-17 12:28:35 +12:00
Ralph Slooten
84a519e84d Fix: Include SMTPUTF8 capability in SMTP EHLO response (#496) 2025-05-17 01:09:17 +12:00
Ralph Slooten
e1a6904eca Chore: Upgrade to jhillyerd/enmime/v2 2025-05-17 00:34:29 +12:00
Ralph Slooten
bc200c663f Docs: Add Message ListUnsubscribe to swagger / API documentation (#494) 2025-05-13 19:27:27 +12:00
Ralph Slooten
009f3a8fd9 Docs: Switch to git-cliff for changelog generation 2025-05-03 23:02:57 +12:00
Ralph Slooten
cfe695c35d Merge tag 'v1.24.2' into develop
Release v1.24.2
2025-05-03 16:17:15 +12:00
Ralph Slooten
5eb77cbb18 Merge branch 'release/v1.24.2' 2025-05-03 16:17:11 +12:00
Ralph Slooten
5ab8486a6c Release v1.24.2 2025-05-03 16:17:10 +12:00
Ralph Slooten
8691afd850 Chore: Update caniemail database 2025-05-03 16:15:53 +12:00
Ralph Slooten
3517ec42c9 Chore: Update node dependencies 2025-05-03 16:14:53 +12:00
Ralph Slooten
9dada2fd30 Chore: Update Go dependencies 2025-05-03 16:08:23 +12:00
Ralph Slooten
84a7d8b30d Update README to reflect script usage and allow custom INSTALL_PATH 2025-05-01 17:26:46 +12:00
Ralph Slooten
a50d80b5fc Merge branch 'feature/installer' into develop 2025-05-01 16:14:58 +12:00
Ralph Slooten
5e1a228328 Allow INSTALL_PATH to be overridden by environment variable 2025-05-01 15:57:57 +12:00
Ralph Slooten
b4f4b857f3 Minor tweaks to installer 2025-05-01 15:47:52 +12:00
kallookoo
658c94a2d1 Converts to sh for compatibility. 2025-04-30 18:37:17 +02:00
Matt Currie
05375fed7a Feature: Display unread count in app badge (#485)
* Display unread count in app badge

* Rate limit app badge updates
2025-04-30 17:34:46 +12:00
kallookoo
5961bf000d Refactor install script for improved error handling and OS detection 2025-04-24 15:23:34 +02:00
Ralph Slooten
87c67e1b1f Chore: Install script improvements & better error handling (#482) 2025-04-23 16:23:44 +12:00
Ralph Slooten
c79abb3e5f Merge tag 'v1.24.1' into develop
Release v1.24.1
2025-04-12 19:26:53 +12:00
Ralph Slooten
8ce5a35e3b Merge branch 'release/v1.24.1' 2025-04-12 19:26:50 +12:00
Ralph Slooten
dd0ba8b09d Release v1.24.1 2025-04-12 19:26:48 +12:00
Ralph Slooten
f18b8f8fb1 Chore: Update node dependencies 2025-04-12 19:09:54 +12:00
Ralph Slooten
c76adb8c01 Chore: Update Go dependencies 2025-04-12 18:27:01 +12:00
Ralph Slooten
1b95f2fe39 Remove breaking swagger example 2025-04-06 19:08:31 +12:00
Ralph Slooten
1400936760 Feature: Add ability to mark all search results as read (#476) 2025-04-06 18:11:37 +12:00
Ralph Slooten
04289091bc Chore: Improve error message for From header parsing failure (#477) 2025-04-05 15:51:29 +13:00
Ralph Slooten
6acbbb4446 Chore: Bump node version to 22 for binary releases 2025-04-04 16:26:50 +13:00
Ralph Slooten
bc9a5cd4c2 Merge tag 'v1.24.0' into develop
Release v1.24.0
2025-03-29 22:35:02 +13:00
Ralph Slooten
85a2c1502a Release v1.24.0 2025-03-29 22:35:01 +13:00
Ralph Slooten
15de95ff62 Merge branch 'release/v1.24.0' 2025-03-29 22:34:28 +13:00
Ralph Slooten
019613004d Release v1.24.0 2025-03-29 22:29:58 +13:00
Ralph Slooten
c204339dbb Chore: Update node dependencies 2025-03-29 22:20:21 +13:00
Ralph Slooten
981ccd2a74 Chore: Update Go dependencies 2025-03-29 22:20:21 +13:00
Ralph Slooten
20b2eb22d4 Chore: Standardize error message casing 2025-03-29 22:20:10 +13:00
Ralph Slooten
6c0ef5ba33 Feature: Add TLS forwarding support and refactor forwarding function 2025-03-29 22:20:09 +13:00
San Chen
2dbc4ea601 Feature: Add TLS relay support and refactor relay function (#471)
* Feature: Add TLS relay support and refactoring the relay function

* Fix: Prevent simultaneous use of TLS and STARTTLS in relay configuration validation
2025-03-29 22:20:09 +13:00
Ralph Slooten
54b6d8f85c Standardize error message casing in SMTP and POP3 configuration validation 2025-03-29 22:20:08 +13:00
Ralph Slooten
5e84633e76 Release v1.23.2 2025-03-29 22:20:08 +13:00
Ralph Slooten
7fbff71689 Update node dependencies 2025-03-29 22:20:07 +13:00
Ralph Slooten
164e7c150d Chore: Update Go dependencies 2025-03-29 22:20:07 +13:00
Ralph Slooten
d87e3087f3 Push ghcr.io :latest tag last to feature first 2025-03-29 22:20:06 +13:00
Ralph Slooten
56ca3afbad Merge branch 'release/v1.23.2' 2025-03-16 21:16:09 +13:00
Ralph Slooten
b7fa68dff9 Release v1.23.2 2025-03-16 21:16:08 +13:00
Ralph Slooten
5214739618 Update node dependencies 2025-03-16 21:13:51 +13:00
Ralph Slooten
2bb2036380 Chore: Update Go dependencies 2025-03-16 21:11:50 +13:00
Ralph Slooten
de693c9c68 Push ghcr.io :latest tag last to feature first 2025-03-15 12:03:38 +13:00
Ralph Slooten
bb5ea68f03 Merge branch 'feature/htmlcheck' into develop 2025-03-15 11:55:44 +13:00
Ralph Slooten
b4131dbeae Testing: Add tests for inline HTML Checks 2025-03-15 11:55:03 +13:00
Ralph Slooten
e3e1d734b6 Chore: Improve inline HTML Check style detection (#467)
Using goquery sometimes resulted in incorrect partial matches, eg `transform:` matching `text-transform:`. This refactor switches to regex matches which should prevent this, and allow more accurate detection.
2025-03-15 11:54:22 +13:00
Ralph Slooten
25671ba94f Chore: Use Message-ID header instead of Message-Id when generating new IDs (RFC 5322) 2025-03-13 17:34:10 +13:00
Ralph Slooten
290ffdd80c Chore: Update node dependencies 2025-03-12 17:09:31 +13:00
Ralph Slooten
753591105a Merge tag 'v1.23.1' into develop
Release v1.23.1
2025-03-08 23:01:20 +13:00
Ralph Slooten
5d0bbe74e0 Merge branch 'release/v1.23.1' 2025-03-08 23:01:16 +13:00
Ralph Slooten
ff1751350f Release v1.23.1 2025-03-08 23:01:15 +13:00
Ralph Slooten
fdd3cb3074 Chore: Update node dependencies 2025-03-08 22:56:32 +13:00
Ralph Slooten
4f81fb417f Chore: Update Go dependencies 2025-03-08 22:52:25 +13:00
Ralph Slooten
39886cf57c Fix: Prevent cropping bottom of label characters in web UI (#457) 2025-03-08 22:49:07 +13:00
Ralph Slooten
9a1f3a6bb5 Chore: Replace PrismJS with highlight.js for HTML syntax highlighting 2025-03-05 17:14:06 +13:00
Ralph Slooten
ac9b7de295 Fix: Allow searching messages using only Cyrillic characters (#450) 2025-03-04 16:51:19 +13:00
Ralph Slooten
d4406cf02b Merge tag 'v1.23.0' into develop
Release v1.23.0
2025-03-01 23:27:56 +13:00
Ralph Slooten
577461bff4 Merge branch 'release/v1.23.0' 2025-03-01 23:27:44 +13:00
Ralph Slooten
289466bdb8 Release v1.23.0 2025-03-01 23:27:43 +13:00
Ralph Slooten
3c2e227d32 Ignore gosec warnings for dump folder / file permissions 2025-03-01 23:11:24 +13:00
Ralph Slooten
7dfdf54e97 Chore: Update node dependencies 2025-03-01 23:02:34 +13:00
Ralph Slooten
f61a390bd9 Chore: Update Go dependencies 2025-03-01 22:59:46 +13:00
Ralph Slooten
b827d75c3e Feature: Add configuration to disable SQLite WAL mode for NFS compatibility 2025-03-01 22:51:42 +13:00
Ralph Slooten
784e3de8a1 Testing: Add tests for message compression levels 2025-03-01 22:51:41 +13:00
Ralph Slooten
876d0eb5da Feature: Add configuration to explicitly disable HTTP compression in web UI/API (#448) 2025-03-01 22:51:22 +13:00
Ralph Slooten
6e9760d5d9 Feature: Add configuration to set message compression level in db (0-3) (#447 & #448) 2025-03-01 22:51:22 +13:00
Ralph Slooten
aafd2a20d9 Chore: Minor speed & memory improvements when storing messages 2025-03-01 22:51:21 +13:00
Ralph Slooten
284e66f0ba Chore: Optimize ZSTD encoder for fastest compression of messages (#447) 2025-03-01 22:51:21 +13:00
Ralph Slooten
8995cddfa5 Chore: Handle BLOB storage for default database differently to rqlite to reduce memory overhead (#447) 2025-03-01 22:51:20 +13:00
Ralph Slooten
8401ffff22 Fix: Display the correct STARTTLS or TLS runtime option on startup (#446)
This is just a cosmetic fix as the functionality itself was working correctly, however the runtime log said "STARTTLS required" regardless which was set.
2025-03-01 22:51:20 +13:00
Ville Skyttä
a6d0db174b Chore: Avoid shell in Docker health check (#444) 2025-03-01 22:51:19 +13:00
74 changed files with 3269 additions and 2233 deletions

View File

@@ -1,47 +0,0 @@
# Changelog
Notable changes to Mailpit will be documented in this file.
{{ if .Versions -}}
{{ if .Unreleased.CommitGroups -}}
## [Unreleased]
{{ if .Unreleased.CommitGroups -}}
{{ range .Unreleased.CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}
{{ range .Versions }}
{{- if .CommitGroups -}}
## [{{ .Tag.Name }}]
{{ if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end -}}
{{ end }}
{{ end -}}
{{ end -}}
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end }}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
- {{ .Header }}
{{ end }}
{{ end }}
{{ end -}}

View File

@@ -1,12 +0,0 @@
{{ if .Versions -}}
{{ range .Versions }}
{{- if .CommitGroups -}}
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}
{{ end -}}

View File

@@ -1,46 +0,0 @@
style: github
template: CHANGELOG.tpl.md
info:
title: CHANGELOG
repository_url: https://github.com/axllent/mailpit
options:
commits:
# filters:
# Type:
# - feat
# - fix
# - perf
# - refactor
commit_groups:
title_maps:
feature: Feature
fix: Fix
# perf: Performance Improvements
# refactor: Code Refactoring
sort_by: Custom
title_order:
- Feature
- Chore
- UI
- API
- Libs
- Docker
- Security
- Fix
- Bugfix
- Docs
- Swagger
- Build
- Testing
- Test
- Tests
- Pull Requests
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:
- Type
- Scope
- Subject
notes:
keywords:
- BREAKING CHANGE

48
.github/cliff.toml vendored Normal file
View File

@@ -0,0 +1,48 @@
## https://git-cliff.org/
[changelog]
body = """
{% if version %}\
\n## [{{ version }}]
{% else %}\
\n## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}\
{% for commit in commits %}
- {{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
footer = ""
header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file."
postprocessors = [
{pattern = "reponse", replace = "response"},
{pattern = "messsage", replace = "message"},
{pattern = '(?i) go modules', replace = " Go dependencies"},
{pattern = '(?i) node modules', replace = " node dependencies"},
{pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"},
]
trim = true
[git]
# HTML comments added for grouping order, stripped on generation
commit_parsers = [
{body = ".*security", group = "<!-- 1 -->Security"},
{message = "(?i)^feat", group = "<!-- 2 -->Feature"},
{message = "(?i)^chore", group = "<!-- 3 -->Chore"},
{message = "(?i)^libs", group = "<!-- 3 -->Chore"},
{message = "(?i)^ui", group = "<!-- 3 -->Chore"},
{message = "(?i)^api", group = "<!-- 4 -->API"},
{message = "(?i)^fix", group = "<!-- 5 -->Fix"},
{message = "(?i)^doc", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
{message = "(?i)^swagger", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
{message = "(?i)^test", group = "<!-- 7 -->Test"},
]
# Exclude commits that are not matched by any commit parser.
# filter_commits = true
# Order releases topologically instead of chronologically.
# topo_order = true
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"

View File

@@ -48,6 +48,6 @@ jobs:
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest

View File

@@ -26,7 +26,7 @@ jobs:
# build the assets
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
cache: 'npm'
- run: echo "Building assets for ${{ github.ref_name }}"
- run: npm install

View File

@@ -26,7 +26,7 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/linkcheck -v
- 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 ./internal/html2text -bench=.
# build the assets
@@ -34,7 +34,7 @@ jobs:
if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
cache: 'npm'
- if: startsWith(matrix.os, 'ubuntu') == true
run: npm install

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,6 @@ RUN apk upgrade --no-cache && apk add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD /mailpit readyz
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
ENTRYPOINT ["/mailpit"]

View File

@@ -68,12 +68,18 @@ Mailpit runs as a single binary and can be installed in different ways:
- **FreeBSD**: `pkg install mailpit`
### Install via bash script (Linux & Mac)
### Install via script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```shell
sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
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)
```

View File

@@ -9,6 +9,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/storage"
@@ -39,6 +40,14 @@ Documentation:
os.Exit(1)
}
// Start Prometheus metrics if enabled
switch prometheus.GetMode() {
case "integrated":
prometheus.StartUpdater()
case "separate":
go prometheus.StartSeparateServer()
}
go server.Listen()
if err := smtpd.Listen(); err != nil {
@@ -83,12 +92,14 @@ func init() {
initConfigFromEnv()
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().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().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().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")
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
@@ -103,6 +114,12 @@ func init() {
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
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)")
rootCmd.Flags().BoolVar(&config.HideDeleteAllButton, "hide-delete-all-button", config.HideDeleteAllButton, "Hide the \"Delete all\" button in the web UI")
// Send API
rootCmd.Flags().StringVar(&config.SendAPIAuthFile, "send-api-auth-file", config.SendAPIAuthFile, "A password file for Send API authentication")
rootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, "send-api-auth-accept-any", config.SendAPIAuthAcceptAny, "Accept any username and password for the Send API endpoint, including none")
// SMTP server
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
@@ -142,6 +159,9 @@ func init() {
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
// Prometheus metrics
rootCmd.Flags().StringVar(&config.PrometheusListen, "enable-prometheus", config.PrometheusListen, "Enable Prometheus metrics: true|false|<ip:port> (eg:'0.0.0.0:9090')")
// 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")
@@ -181,6 +201,12 @@ func initConfigFromEnv() {
config.Database = os.Getenv("MP_DATABASE")
}
config.DisableWAL = getEnabledFromEnv("MP_DISABLE_WAL")
if len(os.Getenv("MP_COMPRESSION")) > 0 {
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
}
config.TenantID = os.Getenv("MP_TENANT_ID")
config.Label = os.Getenv("MP_LABEL")
@@ -232,6 +258,21 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
config.AllowUntrustedTLS = true
}
if getEnabledFromEnv("MP_DISABLE_HTTP_COMPRESSION") {
config.DisableHTTPCompression = true
}
if getEnabledFromEnv("MP_HIDE_DELETE_ALL_BUTTON") {
config.HideDeleteAllButton = true
}
// Send API
config.SendAPIAuthFile = os.Getenv("MP_SEND_API_AUTH_FILE")
if err := auth.SetSendAPIAuth(os.Getenv("MP_SEND_API_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
if getEnabledFromEnv("MP_SEND_API_AUTH_ACCEPT_ANY") {
config.SendAPIAuthAcceptAny = true
}
// SMTP server
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
@@ -280,6 +321,7 @@ func initConfigFromEnv() {
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
}
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
config.SMTPRelayConfig.TLS = getEnabledFromEnv("MP_SMTP_RELAY_TLS")
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
@@ -298,6 +340,7 @@ func initConfigFromEnv() {
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
}
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
config.SMTPForwardConfig.TLS = getEnabledFromEnv("MP_SMTP_FORWARD_TLS")
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
@@ -328,6 +371,11 @@ func initConfigFromEnv() {
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
// Prometheus metrics
if len(os.Getenv("MP_ENABLE_PROMETHEUS")) > 0 {
config.PrometheusListen = os.Getenv("MP_ENABLE_PROMETHEUS")
}
// Webhook
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
config.WebhookURL = os.Getenv("MP_WEBHOOK_URL")
@@ -344,9 +392,9 @@ func initConfigFromEnv() {
func initDeprecatedConfigFromEnv() {
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
if len(os.Getenv("MP_DATA_FILE")) > 0 {
logger.Log().Warn("ENV MP_DATA_FILE has been deprecated, use MP_DATABASE")
config.Database = os.Getenv("MP_DATA_FILE")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")

View File

@@ -28,6 +28,14 @@ var (
// Database for mail (optional)
Database string
// DisableWAL will disable Write-Ahead Logging in SQLite
// @see https://sqlite.org/wal.html
DisableWAL 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
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID string
@@ -61,6 +69,15 @@ var (
// Webroot to define the base path for the UI and API
Webroot = "/"
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
DisableHTTPCompression bool
// SendAPIAuthFile for Send API authentication
SendAPIAuthFile string
// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none
SendAPIAuthAcceptAny bool
// SMTPTLSCert file
SMTPTLSCert string
@@ -162,6 +179,9 @@ var (
// EnableSpamAssassin must be either <host>:<port> or "postmark"
EnableSpamAssassin string
// HideDeleteAllButton hides the delete all button in the web UI
HideDeleteAllButton bool
// WebhookURL for calling
WebhookURL string
@@ -171,6 +191,10 @@ var (
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
AllowUntrustedTLS bool
// PrometheusListen address for Prometheus metrics server
// Empty = disabled, true= use existing web server, address = separate server
PrometheusListen string
// Version is the default application version, updated on release
Version = "dev"
@@ -198,10 +222,11 @@ type autoTag struct {
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
type SMTPRelayConfigStruct struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
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
@@ -223,7 +248,8 @@ type SMTPForwardConfigStruct struct {
Host string `yaml:"host"` // SMTP host
Port int `yaml:"port"` // SMTP port
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication
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
@@ -250,6 +276,10 @@ func VerifyConfig() error {
Database = filepath.Join(Database, "mailpit.db")
}
if Compression < 0 || Compression > 3 {
return errors.New("[db] compression level must be between 0 and 3")
}
Label = tools.Normalize(Label)
if err := parseMaxAge(); err != nil {
@@ -269,6 +299,7 @@ func VerifyConfig() error {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
// Web UI & API
if UIAuthFile != "" {
UIAuthFile = filepath.Clean(UIAuthFile)
@@ -303,8 +334,51 @@ func VerifyConfig() error {
}
}
// Send API
if SendAPIAuthFile != "" {
SendAPIAuthFile = filepath.Clean(SendAPIAuthFile)
if !isFile(SendAPIAuthFile) {
return fmt.Errorf("[send-api] password file not found or readable: %s", SendAPIAuthFile)
}
b, err := os.ReadFile(SendAPIAuthFile)
if err != nil {
return err
}
if err := auth.SetSendAPIAuth(string(b)); err != nil {
return err
}
logger.Log().Info("[send-api] enabling basic authentication")
}
if auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {
return errors.New("[send-api] authentication cannot use both credentials and --send-api-auth-accept-any")
}
if SendAPIAuthAcceptAny && auth.UICredentials != nil {
logger.Log().Info("[send-api] disabling authentication")
}
// Prometheus configuration validation
if PrometheusListen != "" {
mode := strings.ToLower(strings.TrimSpace(PrometheusListen))
if mode != "true" && mode != "false" {
// Validate as address for separate server mode
_, err := net.ResolveTCPAddr("tcp", PrometheusListen)
if err != nil {
return fmt.Errorf("[prometheus] %s", err.Error())
}
} else if mode == "true" {
logger.Log().Info("[prometheus] enabling metrics")
}
}
// SMTP server
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
return errors.New("[smtp] you must provide both an SMTP TLS certificate and a key")
}
if SMTPTLSCert != "" {
@@ -389,7 +463,7 @@ func VerifyConfig() error {
}
}
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
return errors.New("[pop3] you must provide both a POP3 TLS certificate and a key")
}
if POP3Listen != "" {
_, err := net.ResolveTCPAddr("tcp", POP3Listen)

View File

@@ -8,7 +8,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
"github.com/goccy/go-yaml"
)
var (

View File

@@ -12,7 +12,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"gopkg.in/yaml.v3"
"github.com/goccy/go-yaml"
)
// Parse the --max-age value (if set)
@@ -145,6 +145,10 @@ func validateRelayConfig() error {
SMTPRelayConfig.OverrideFrom = m.Address
}
if SMTPRelayConfig.STARTTLS && SMTPRelayConfig.TLS {
return fmt.Errorf("[relay] TLS & STARTTLS cannot be required together")
}
ReleaseEnabled = true
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
@@ -243,6 +247,10 @@ func validateForwardConfig() error {
SMTPForwardConfig.OverrideFrom = m.Address
}
if SMTPForwardConfig.STARTTLS && SMTPForwardConfig.TLS {
return fmt.Errorf("[forward] TLS & STARTTLS cannot be required together")
}
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
return nil

51
go.mod
View File

@@ -4,60 +4,69 @@ go 1.23.0
toolchain go1.23.2
// https://github.com/jaytaylor/html2text/issues/67
replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5
require (
github.com/PuerkitoBio/goquery v1.10.2
github.com/PuerkitoBio/goquery v1.10.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/goccy/go-yaml v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.3.0
github.com/klauspost/compress v1.17.11
github.com/jhillyerd/enmime/v2 v2.1.0
github.com/klauspost/compress v1.18.0
github.com/kovidgoyal/imaging v1.6.4
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.22.0
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/tg123/go-htpasswd v1.2.3
github.com/vanng822/go-premailer v1.23.0
golang.org/x/net v0.35.0
golang.org/x/text v0.22.0
golang.org/x/time v0.10.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.35.0
github.com/tg123/go-htpasswd v1.2.4
github.com/vanng822/go-premailer v1.24.0
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
golang.org/x/text v0.26.0
golang.org/x/time v0.12.0
modernc.org/sqlite v1.37.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/dustin/go-humanize v1.0.1 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/olekukonko/tablewriter v1.0.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // 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
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.61.13 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/image v0.28.0 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/memory v1.11.0 // indirect
)

160
go.sum
View File

@@ -1,34 +1,38 @@
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.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
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/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
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/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/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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/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-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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=
@@ -41,19 +45,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/jhillyerd/enmime/v2 v2.1.0 h1:c8Qwi5Xq5EdtMN6byQWoZ/8I2RMTo6OJ7Xay+s1oPO0=
github.com/jhillyerd/enmime/v2 v2.1.0/go.mod h1:EJ74dcRbBcqHSP2TBu08XRoy6y3Yx0cevwb1YkGMEmQ=
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.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/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=
@@ -68,6 +67,8 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp
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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -76,6 +77,14 @@ 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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
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.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
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=
@@ -84,16 +93,14 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.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.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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.9.0 h1:Py5fIuq/lJsRYxcxfOtsJqpmwJWCMOUy2tMJYV8TNHE=
github.com/spf13/cobra v1.9.0/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -101,60 +108,51 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tg123/go-htpasswd v1.2.3 h1:ALR6ZBIc2m9u70m+eAWUFt5p43ISbIvAvRFYzZPTOY8=
github.com/tg123/go-htpasswd v1.2.3/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
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.23.0 h1:vZp2wuz1jb4q/DurUV18VGjXWtTFYZHwTCw2EAWKO74=
github.com/vanng822/go-premailer v1.23.0/go.mod h1:0+z0UJ6ZGQatzkWlaQNl50M7fLz5f6FcP8V2p0oie88=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/vanng822/go-premailer v1.24.0 h1:b4MpHLVdlA7QOwk5OJIEvWnIpCCdEhEDQpJ/AkEYcpo=
github.com/vanng822/go-premailer v1.24.0/go.mod h1:gjLku4P5inmyu+MM7544lOjhaW8F3TdIqboFVcZGwZE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
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.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
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.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
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.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
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=
@@ -162,40 +160,32 @@ 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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.0.0-20220908164124-27713097b956/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.7.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.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.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -205,47 +195,45 @@ 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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
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.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,98 +1,214 @@
#!/usr/bin/env bash
#!/bin/sh
GH_REPO="axllent/mailpit"
TIMEOUT=90
# This script will install the latest release of Mailpit.
set -e
# 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 "Please install $cmd and try again."
exit 1
fi
done
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ $? -ne 0 ]; then
echo -ne "\nThere was an error trying to check what is the latest version of Mailpit.\nPlease try again later.\n"
exit 1
fi
# detect the platform
OS="$(uname)"
case $OS in
Linux)
OS='linux'
;;
FreeBSD)
OS='freebsd'
echo 'OS not supported'
exit 2
;;
NetBSD)
OS='netbsd'
echo 'OS not supported'
exit 2
;;
OpenBSD)
OS='openbsd'
echo 'OS not supported'
exit 2
;;
Darwin)
OS='darwin'
;;
SunOS)
OS='solaris'
echo 'OS not supported'
exit 2
;;
# Check if the OS is supported.
OS=
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="Darwin" ;;
*)
echo 'OS not supported'
echo "OS not supported."
exit 2
;;
esac
# detect the arch
OS_type="$(uname -m)"
case "$OS_type" in
# Detect the architecture of the OS.
OS_ARCH=
case "$(uname -m)" in
x86_64 | amd64)
OS_type='amd64'
OS_ARCH="amd64"
;;
i?86 | x86)
OS_type='386'
OS_ARCH="386"
;;
aarch64 | arm64)
OS_type='arm64'
OS_ARCH="arm64"
;;
*)
echo 'OS type not supported'
echo "OS architecture not supported."
exit 2
;;
esac
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
GH_REPO="axllent/mailpit"
INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin}"
TIMEOUT=90
# This is used to authenticate with the GitHub API. (Fix the public rate limiting issue)
# Try the GITHUB_TOKEN environment variable is set globally.
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
#create tmp directory and move to it with macOS compatibility fallback
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
cd "$tmp_dir"
# Update the default values if the user has set.
while [ $# -gt 0 ]; do
case $1 in
--install-path)
shift
case "$1" in
*/*)
# Remove trailing slashes from the path.
INSTALL_PATH="$(echo "$1" | sed 's#/\+$##')"
[ -z "$INSTALL_PATH" ] && INSTALL_PATH="/"
;;
esac
;;
--auth | --auth-token | --github-token | --token)
shift
case "$1" in
gh*)
GITHUB_API_TOKEN="$1"
;;
esac
;;
*) ;;
esac
shift
done
echo "Downloading Mailpit $VERSION"
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
# Description of the sort parameters for curl command.
# -s: Silent mode.
# -f: Fail silently on server errors.
# -L: Follow redirects.
# -m: Set maximum time allowed for the transfer.
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
echo "Error downloading"
exit 2
}
if [ -n "$GITHUB_API_TOKEN" ] && [ "${#GITHUB_API_TOKEN}" -gt 36 ]; then
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT -H "Authorization: Bearer $GITHUB_API_TOKEN" https://api.github.com/repos/${GH_REPO}/releases/latest)"
EXIT_CODE=$?
else
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT https://api.github.com/repos/${GH_REPO}/releases/latest)"
EXIT_CODE=$?
fi
mkdir -p /usr/local/bin || exit 2
cp mailpit /usr/local/bin/ || exit 2
chmod 755 /usr/local/bin/mailpit || exit 2
case "$OS" in
'linux')
chown root:root /usr/local/bin/mailpit || exit 2
;;
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
chown root:wheel /usr/local/bin/mailpit || exit 2
;;
VERSION=""
if [ $EXIT_CODE -eq 0 ]; then
# Extracts the latest version using jq, awk, or sed.
if command -v jq >/dev/null 2>&1; then
# Use jq -n because the output is not a valid JSON in sh.
VERSION=$(jq -n "$CURL_OUTPUT" | jq -r '.tag_name')
elif command -v awk >/dev/null 2>&1; then
VERSION=$(echo "$CURL_OUTPUT" | awk -F: '$1 ~ /tag_name/ {gsub(/[^v0-9\.]+/, "", $2) ;print $2; exit}')
elif command -v sed >/dev/null 2>&1; then
VERSION=$(echo "$CURL_OUTPUT" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
else
EXIT_CODE=3
fi
fi
# Validate the version.
case "$VERSION" in
v[0-9][0-9\.]*) ;;
*)
echo 'OS not supported'
exit 2
echo "There was an error trying to check what is the latest version of Mailpit."
echo "Please try again later."
exit $EXIT_CODE
;;
esac
rm -rf "$tmp_dir"
echo "Installed successfully to /usr/local/bin/mailpit"
TEMP_DIR="$(mktemp -qd)"
EXIT_CODE=$?
# Ensure the temporary directory exists and is a directory.
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
echo "ERROR: Creating temporary directory."
exit $EXIT_CODE
fi
GH_REPO_BIN="mailpit-${OS}-${OS_ARCH}.tar.gz"
if [ "$INSTALL_PATH" = "/" ]; then
INSTALL_BIN_PATH="/mailpit"
else
INSTALL_BIN_PATH="${INSTALL_PATH}/mailpit"
fi
cd "$TEMP_DIR" || EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
# Download the latest release.
#
# Description of the sort parameters for curl command.
# -s: Silent mode.
# -f: Fail silently on server errors.
# -L: Follow redirects.
# -m: Set maximum time allowed for the transfer.
# -o: Write output to a file instead of stdout.
curl -sfL -m $TIMEOUT -o "${GH_REPO_BIN}" "https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
EXIT_CODE=$?
# The following conditions check each step of the installation.
# If there is an error in any of the steps, an error message is printed.
if [ $EXIT_CODE -eq 0 ]; then
if ! [ -f "${GH_REPO_BIN}" ]; then
EXIT_CODE=1
echo "ERROR: Downloading latest release."
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
tar zxf "$GH_REPO_BIN"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Extracting \"${GH_REPO_BIN}\"."
fi
fi
if [ $EXIT_CODE -eq 0 ] && [ ! -d "$INSTALL_PATH" ]; then
mkdir -p "${INSTALL_PATH}"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Creating \"${INSTALL_PATH}\" directory."
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
cp mailpit "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Copying mailpit to \"${INSTALL_PATH}\" directory."
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
chmod 755 "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Setting permissions for \"$INSTALL_BIN_PATH\" binary."
fi
fi
# Set the owner and group to root:root if the script is run as root.
if [ $EXIT_CODE -eq 0 ] && [ "$(id -u)" -eq "0" ]; then
OWNER="root"
GROUP="root"
# Set the OWNER, GROUP variable when the OS not use the default root:root.
case "$OS" in
darwin) GROUP="wheel" ;;
*) ;;
esac
chown "${OWNER}:${GROUP}" "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Setting ownership for \"$INSTALL_BIN_PATH\" binary."
fi
fi
else
echo "ERROR: Changing 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\"."
exit 0

View File

@@ -11,6 +11,8 @@ import (
var (
// UICredentials passwords
UICredentials *htpasswd.File
// SendAPICredentials passwords
SendAPICredentials *htpasswd.File
// SMTPCredentials passwords
SMTPCredentials *htpasswd.File
// POP3Credentials passwords
@@ -36,6 +38,25 @@ func SetUIAuth(s string) error {
return nil
}
// SetSendAPIAuth will set Send API credentials
func SetSendAPIAuth(s string) error {
var err error
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
return nil
}
// SetSMTPAuth will set SMTP credentials
func SetSMTPAuth(s string) error {
var err error

View File

@@ -50,7 +50,7 @@ func Sync(d string) error {
}
if !tools.IsDir(outDir) {
if err := os.MkdirAll(outDir, 0755); err != nil {
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
return err
}
}
@@ -130,27 +130,27 @@ func saveMessages() error {
res, err := http.Get(base + "api/v1/message/" + m.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", m.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())
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
continue
}
} else {
var err error
b, err = storage.GetMessageRaw(m.ID)
if err != nil {
logger.Log().Errorf("Error fetching message %s: %s", m.ID, err.Error())
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
continue
}
}
if err := os.WriteFile(out, b, 0644); err != nil {
logger.Log().Errorf("Error writing message %s: %s", m.ID, err.Error())
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
continue
}

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2024-11-29 15:25:23 +0000",
"last_update_date":"2025-05-17 12:48:23 +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":[
{
@@ -190,9 +190,9 @@
"last_test_date":"2019-08-20",
"test_url":"https://www.caniemail.com/tests/css-media.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list",
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"n","12":"y","13":"y","15":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"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":{"10.3":"n","12":"y","13":"y","15":"y","18.3.2":"a #2"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y","2025-04":"n"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"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":"Buggy. The first rule inside a media query is not prefixed."}
"notes_by_num":{"1":"Buggy. The first rule inside a media query is not prefixed.","2":"Partial. `orientation:portrait` is not supported."}
},
{
@@ -521,7 +521,7 @@
"description":"Support for border radius logical properties",
"url":"https://www.caniemail.com/features/css-border-radius-logical/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius",
"last_test_date":"2022-08-16",
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
@@ -910,7 +910,7 @@
"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"}}},
"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"}}},
"notes":null,
"notes_by_num":null
},
@@ -1086,7 +1086,7 @@
"last_test_date":"2019-09-27",
"test_url":"https://www.caniemail.com/tests/css-width-height.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"y #1"},"ios":{"2019-09":"y #1"},"android":{"2019-09":"y #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"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":"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":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"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":"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":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
},
@@ -1177,8 +1177,8 @@
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
"url":"https://www.caniemail.com/features/css-inset/",
"category":"css",
"tags":[],
"keywords":null,
"tags":["i18n"],
"keywords":"inset-block, inset-inline, inset-inline-start, inset-inline-end, inset-block-start, inset-block-end",
"last_test_date":"2024-05-29",
"test_url":"https://www.caniemail.com/tests/css-inset.html",
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
@@ -1230,9 +1230,9 @@
"last_test_date":"2021-05-16",
"test_url":"https://www.caniemail.com/tests/css-positioning.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list",
"stats":{"apple-mail":{"macos":{"14":"y"},"ios":{"14.5":"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y"}},"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":"a #1"}},"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":{"14":"y"},"ios":{"14.5":"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":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y","6.2.01.1":"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":"a #1"}},"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. `left` and `top` are not supported."}
"notes_by_num":{"1":"Partial. `left` and `top` are not supported.","2":"Partial. Percentages values are not supported."}
},
{
@@ -1278,9 +1278,9 @@
"last_test_date":"2021-12-29",
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"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":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #3"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"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":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts."}
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts.","3":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
},
{
@@ -1374,7 +1374,7 @@
"last_test_date":"2022-07-12",
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"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"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":null
},
@@ -1395,22 +1395,6 @@
"notes_by_num":null
},
{
"slug":"css-margin-inline",
"title":"margin-inline & margin-block",
"description":"Support for the `margin-inline` and `margin-block` shorthand properties.",
"url":"https://www.caniemail.com/features/css-margin-inline/",
"category":"css",
"tags":[],
"keywords":"margin-inline, margin-block",
"last_test_date":"2022-07-01",
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-margin",
"title":"margin",
@@ -1449,7 +1433,7 @@
"description":"",
"url":"https://www.caniemail.com/features/css-max-block-size/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"max, block, size",
"last_test_date":"2022-09-01",
"test_url":"https://www.caniemail.com/tests/css-max-block-size.html",
@@ -1481,7 +1465,7 @@
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
"url":"https://www.caniemail.com/features/css-max-inline-size/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"max, inline, size",
"last_test_date":"2024-05-31",
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
@@ -1513,7 +1497,7 @@
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
"url":"https://www.caniemail.com/features/css-min-block-size/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"min, block, size",
"last_test_date":"2024-05-31",
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
@@ -1545,7 +1529,7 @@
"description":"",
"url":"https://www.caniemail.com/features/css-min-inline-size/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"min, inline, size",
"last_test_date":"2022-08-30",
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
@@ -1614,7 +1598,7 @@
"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"}},"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":"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"}}},
"notes":null,
"notes_by_num":{"1":"Partial. `E { F {}}` doesnt work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."}
},
@@ -1726,9 +1710,9 @@
"last_test_date":"2022-08-03",
"test_url":"https://www.caniemail.com/tests/css-overflow-wrap.html",
"test_results_url":"https://testi.at/proj/zxOsWrYsJqztvWC7JYF8xrUgn",
"stats":{"apple-mail":{"macos":{"16":"n","17":"n","18":"n","19":"n","20":"n","21":"n"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}},
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"18.3.2":"a #1"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}},
"notes":null,
"notes_by_num":null
"notes_by_num":{"1":"Buggy. Requires `word-break:normal` to reset Apple Mail default style (See [issue#394](https://github.com/hteumeuleu/caniemail/issues/394).)"}
},
{
@@ -1738,7 +1722,7 @@
"url":"https://www.caniemail.com/features/css-overflow/",
"category":"css",
"tags":[],
"keywords":null,
"keywords":"overflow-block, overflow-inline",
"last_test_date":"2024-10-02",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://testi.at/proj/p4rru3ez069p15p6ij",
@@ -2254,9 +2238,9 @@
"last_test_date":"2021-12-29",
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #2"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"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":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill)."}
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
},
{
@@ -2777,7 +2761,7 @@
"description":"Sets the orientation of the text characters in vertical mode.",
"url":"https://www.caniemail.com/features/css-text-orientation/",
"category":"css",
"tags":[],
"tags":["i18n"],
"keywords":"vertical orientation",
"last_test_date":"2023-12-08",
"test_url":"https://www.caniemail.com/tests/css-text-orientation.html",
@@ -3385,7 +3369,7 @@
"description":"Represents an abbreviation or acronym.",
"url":"https://www.caniemail.com/features/html-abbr/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2023-09-13",
"test_url":"https://www.caniemail.com/tests/html-abbr.html",
@@ -3566,7 +3550,7 @@
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-background.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Only supported on the `<body>` element."}
},
@@ -3630,7 +3614,7 @@
"last_test_date":"2021-11-30",
"test_url":"https://www.caniemail.com/tests/html-body.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/M1w9fKYqtXsrlJ2mlElp9b2RoSd7lDcWwftkDazPgy4hm/list",
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n","2025-04":"a #1"},"android":{"2021-11":"n","2025-04":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Replaced by a `<div>` with supported attributes."}
},
@@ -3705,7 +3689,7 @@
"description":"A short fragment of computer code.",
"url":"https://www.caniemail.com/features/html-code/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2023-04-25",
"test_url":"https://www.caniemail.com/tests/html-code.html",
@@ -3785,7 +3769,7 @@
"description":"Indicates the directionality of the element's text.",
"url":"https://www.caniemail.com/features/html-dir/",
"category":"html",
"tags":["i18n"],
"tags":["i18n","accessibility"],
"keywords":"direction, ltr, rtl",
"last_test_date":"2021-11-01",
"test_url":"https://www.caniemail.com/tests/css-direction.html",
@@ -3897,7 +3881,7 @@
"description":"HTML horizontal rule",
"url":"https://www.caniemail.com/features/html-hr/",
"category":"html",
"tags":[],
"tags":["accessibility"],
"keywords":null,
"last_test_date":"2023-09-08",
"test_url":"https://www.caniemail.com/tests/html-hr.html",
@@ -3929,7 +3913,7 @@
"description":"Displays an image into the document",
"url":"https://www.caniemail.com/features/html-img/",
"category":"html",
"tags":[],
"tags":["performance","accessibility"],
"keywords":null,
"last_test_date":"2023-12-16",
"test_url":"https://www.caniemail.com/tests/html-img.html",
@@ -4461,8 +4445,8 @@
"keywords":null,
"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/od5IYQtx8yIbIUbeRyQXnP0yzFKEm2E9CKa3FU4BcEXFv/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"},"ios":{"2019-06":"n","2023-02":"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"}},"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"},"ios":{"2023-02":"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"},"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"}}},
"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"},"ios":{"2019-06":"n","2023-02":"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"}},"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"},"ios":{"2023-02":"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).)"}
},

View File

@@ -38,167 +38,178 @@ var htmlTests = map[string]string{
// Image tests using regex to match against img[src]
var imageRegexpTests = map[string]*regexp.Regexp{
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
"image-apng": regexp.MustCompile(`(?i)\.apng$`),
"image-avif": regexp.MustCompile(`(?i)\.avif$`),
"image-base64": regexp.MustCompile(`^(?i)data:image\/`),
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`),
"image-gif": regexp.MustCompile(`(?i)\.gif$`),
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`),
"image-heif": regexp.MustCompile(`(?i)\.heif$`),
"image-ico": regexp.MustCompile(`(?i)\.ico$`),
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`),
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`),
"image-svg": regexp.MustCompile(`(?i)\.svg$`),
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`),
"image-webp": regexp.MustCompile(`(?i)\.webp$`),
}
var cssInlineTests = map[string]string{
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
"css-background": "[style*=\"background:\"], [background]", // 57.407406
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
"css-border": "[style*=\"border:\"], [border]", // 86.95652
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
"css-caption-side": "[style*=\"caption-side:\"]", // 84
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
"css-direction": "[style*=\"direction:\"]", // 97.77778
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
"css-display-none": "[style*=\"display:none\"]", // 84.78261
"css-display": "[style*=\"display:\"]", // 55.555553
"css-filter": "[style*=\"filter:\"]", // 50
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
"css-float": "[style*=\"float:\"]", // 85.10638
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
"css-font": "[style*=\"font:\"]", // 95.833336
"css-gap": "[style*=\"gap:\"]", // 40
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
"css-height": "[style*=\"height:\"], [height]", // 77.08333
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
"css-important": "[style*=\"!important\"]", // 43.478264
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
"css-margin": "[style*=\"margin:\"]", // 71.42857
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
"css-outline": "[style*=\"outline:\"]", // 80.85106
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
"css-position": "[style*=\"position:\"]", // 19.56522
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
"css-rgba": "[style*=\"rgba(\"]", // 56
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
"css-transform": "[style*=\"transform:\"]", // 50
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
"css-variables": "[style*=\"variables:\"]", // 46.551727
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
"css-width": "[style*=\"width:\"], [width]", // 87.5
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
// inline attribute <match>=""
var styleInlineAttributes = map[string]string{
"css-background-color": "[bgcolor]",
"css-background": "[background]",
"css-border": "[border]",
"css-height": "[height]",
"css-padding": "[padding]",
"css-width": "[width]",
}
// inline style="<match>"
var cssInlineRegexTests = map[string]*regexp.Regexp{
"css-accent-color": regexp.MustCompile(`(?i)(^|\s|;)accent-color(\s+)?:`),
"css-align-items": regexp.MustCompile(`(?i)(^|\s|;)align-items(\s+)?:`),
"css-aspect-ratio": regexp.MustCompile(`(?i)(^|\s|;)aspect-ratio(\s+)?:`),
"css-background-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)background-blend-mode(\s+)?:`),
"css-background-clip": regexp.MustCompile(`(?i)(^|\s|;)background-clip(\s+)?:`),
"css-background-color": regexp.MustCompile(`(?i)(^|\s|;)background-color(\s+)?:`),
"css-background-image": regexp.MustCompile(`(?i)(^|\s|;)background-image(\s+)?:`),
"css-background-origin": regexp.MustCompile(`(?i)(^|\s|;)background-origin(\s+)?:`),
"css-background-position": regexp.MustCompile(`(?i)(^|\s|;)background-position(\s+)?:`),
"css-background-repeat": regexp.MustCompile(`(?i)(^|\s|;)background-repeat(\s+)?:`),
"css-background-size": regexp.MustCompile(`(?i)(^|\s|;)background-size(\s+)?:`),
"css-background": regexp.MustCompile(`(?i)(^|\s|;)background(\s+)?:`),
"css-block-inline-size": regexp.MustCompile(`(?i)(^|\s|;)block-inline-size(\s+)?:`),
"css-border-image": regexp.MustCompile(`(?i)(^|\s|;)border-image(\s+)?:`),
"css-border-inline-block-individual": regexp.MustCompile(`(?i)(^|\s|;)border-inline(\s+)?:`),
"css-border-radius": regexp.MustCompile(`(?i)(^|\s|;)border-radius(\s+)?:`),
"css-border": regexp.MustCompile(`(?i)(^|\s|;)border(\s+)?:`),
"css-box-shadow": regexp.MustCompile(`(?i)(^|\s|;)box-shadow(\s+)?:`),
"css-box-sizing": regexp.MustCompile(`(?i)(^|\s|;)box-sizing(\s+)?:`),
"css-caption-side": regexp.MustCompile(`(?i)(^|\s|;)caption-side(\s+)?:`),
"css-clip-path": regexp.MustCompile(`(?i)(^|\s|;)clip-path(\s+)?:`),
"css-column-count": regexp.MustCompile(`(?i)(^|\s|;)column-count(\s+)?:`),
"css-column-layout-properties": regexp.MustCompile(`(?i)(^|\s|;)column-layout-properties(\s+)?:`),
"css-conic-gradient": regexp.MustCompile(`(?i)(^|\s|;)conic-gradient(\s+)?:`),
"css-direction": regexp.MustCompile(`(?i)(^|\s|;)direction(\s+)?:`),
"css-display-flex": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:(\s+)?flex($|\s|;)`),
"css-display-grid": regexp.MustCompile(`(?i)(^|\s|;)display:grid`),
"css-display-none": regexp.MustCompile(`(?i)(^|\s|;)display:none`),
"css-display": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:`),
"css-filter": regexp.MustCompile(`(?i)(^|\s|;)filter(\s+)?:`),
"css-flex-direction": regexp.MustCompile(`(?i)(^|\s|;)flex-direction(\s+)?:`),
"css-flex-wrap": regexp.MustCompile(`(?i)(^|\s|;)flex-wrap(\s+)?:`),
"css-float": regexp.MustCompile(`(?i)(^|\s|;)float(\s+)?:`),
"css-font-kerning": regexp.MustCompile(`(?i)(^|\s|;)font-kerning(\s+)?:`),
"css-font-weight": regexp.MustCompile(`(?i)(^|\s|;)font-weight(\s+)?:`),
"css-font": regexp.MustCompile(`(?i)(^|\s|;)font(\s+)?:`),
"css-gap": regexp.MustCompile(`(?i)(^|\s|;)gap(\s+)?:`),
"css-grid-template": regexp.MustCompile(`(?i)(^|\s|;)grid-template(\s+)?:`),
"css-height": regexp.MustCompile(`(?i)(^|\s|;)height(\s+)?:`),
"css-hyphens": regexp.MustCompile(`(?i)(^|\s|;)hyphens(\s+)?:`),
"css-important": regexp.MustCompile(`(?i)!important($|\s|;)`),
"css-inline-size": regexp.MustCompile(`(?i)(^|\s|;)inline-size(\s+)?:`),
"css-intrinsic-size": regexp.MustCompile(`(?i)(^|\s|;)intrinsic-size(\s+)?:`),
"css-justify-content": regexp.MustCompile(`(?i)(^|\s|;)justify-content(\s+)?:`),
"css-letter-spacing": regexp.MustCompile(`(?i)(^|\s|;)letter-spacing(\s+)?:`),
"css-line-height": regexp.MustCompile(`(?i)(^|\s|;)line-height(\s+)?:`),
"css-list-style-image": regexp.MustCompile(`(?i)(^|\s|;)list-style-image(\s+)?:`),
"css-list-style-position": regexp.MustCompile(`(?i)(^|\s|;)list-style-position(\s+)?:`),
"css-list-style": regexp.MustCompile(`(?i)(^|\s|;)list-style(\s+)?:`),
"css-margin-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-block-(start|end)(\s+)?:`),
"css-margin-inline-block": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-block(\s+)?:`),
"css-margin-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-(start|end)(\s+)?:`),
"css-margin-inline": regexp.MustCompile(`(?i)(^|\s|;)margin-inline(\s+)?:`),
"css-margin": regexp.MustCompile(`(?i)(^|\s|;)margin(\s+)?:`),
"css-max-block-size": regexp.MustCompile(`(?i)(^|\s|;)max-block-size(\s+)?:`),
"css-max-height": regexp.MustCompile(`(?i)(^|\s|;)max-height(\s+)?:`),
"css-max-width": regexp.MustCompile(`(?i)(^|\s|;)max-width(\s+)?:`),
"css-min-height": regexp.MustCompile(`(?i)(^|\s|;)min-height(\s+)?:`),
"css-min-inline-size": regexp.MustCompile(`(?i)(^|\s|;)min-inline-size(\s+)?:`),
"css-min-width": regexp.MustCompile(`(?i)(^|\s|;)min-width(\s+)?:`),
"css-mix-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)mix-blend-mode(\s+)?:`),
"css-modern-color": regexp.MustCompile(`(?i)(^|\s|;)modern-color(\s+)?:`),
"css-object-fit": regexp.MustCompile(`(?i)(^|\s|;)object-fit(\s+)?:`),
"css-object-position": regexp.MustCompile(`(?i)(^|\s|;)object-position(\s+)?:`),
"css-opacity": regexp.MustCompile(`(?i)(^|\s|;)opacity(\s+)?:`),
"css-outline-offset": regexp.MustCompile(`(?i)(^|\s|;)outline-offset(\s+)?:`),
"css-outline": regexp.MustCompile(`(?i)(^|\s|;)outline(\s+)?:`),
"css-overflow-wrap": regexp.MustCompile(`(?i)(^|\s|;)overflow-wrap(\s+)?:`),
"css-overflow": regexp.MustCompile(`(?i)(^|\s|;)overflow(\s+)?:`),
"css-padding-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-block-(start|end)(\s+)?:`),
"css-padding-inline-block": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-block(\s+)?:`),
"css-padding-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-(start|end)(\s+)?:`),
"css-padding": regexp.MustCompile(`(?i)(^|\s|;)padding(\s+)?:`),
"css-position": regexp.MustCompile(`(?i)(^|\s|;)position(\s+)?:`),
"css-radial-gradient": regexp.MustCompile(`(?i)(^|\s|;)radial-gradient(\s+)?:`),
"css-rgb": regexp.MustCompile(`(?i)(\s|:)rgb\(`),
"css-rgba": regexp.MustCompile(`(?i)(\s|:)rgba\(`),
"css-scroll-snap": regexp.MustCompile(`(?i)(^|\s|;)roll-snap(\s+)?:`),
"css-tab-size": regexp.MustCompile(`(?i)(^|\s|;)tab-size(\s+)?:`),
"css-table-layout": regexp.MustCompile(`(?i)(^|\s|;)table-layout(\s+)?:`),
"css-text-align-last": regexp.MustCompile(`(?i)(^|\s|;)text-align-last(\s+)?:`),
"css-text-align": regexp.MustCompile(`(?i)(^|\s|;)text-align(\s+)?:`),
"css-text-decoration-color": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-color(\s+)?:`),
"css-text-decoration-thickness": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-thickness(\s+)?:`),
"css-text-decoration": regexp.MustCompile(`(?i)(^|\s|;)text-decoration(\s+)?:`),
"css-text-emphasis-position": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis-position(\s+)?:`),
"css-text-emphasis": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis(\s+)?:`),
"css-text-indent": regexp.MustCompile(`(?i)(^|\s|;)text-indent(\s+)?:`),
"css-text-overflow": regexp.MustCompile(`(?i)(^|\s|;)text-overflow(\s+)?:`),
"css-text-shadow": regexp.MustCompile(`(?i)(^|\s|;)text-shadow(\s+)?:`),
"css-text-transform": regexp.MustCompile(`(?i)(^|\s|;)text-transform(\s+)?:`),
"css-text-underline-offset": regexp.MustCompile(`(?i)(^|\s|;)text-underline-offset(\s+)?:`),
"css-transform": regexp.MustCompile(`(?i)(^|\s|;)transform(\s+)?:`),
"css-unit-calc": regexp.MustCompile(`(?i)(\s|:)calc\(`),
"css-variables": regexp.MustCompile(`(?i)(^|\s|;)variables(\s+)?:`),
"css-visibility": regexp.MustCompile(`(?i)(^|\s|;)visibility(\s+)?:`),
"css-white-space": regexp.MustCompile(`(?i)(^|\s|;)white-space(\s+)?:`),
"css-width": regexp.MustCompile(`(?i)(^|\s|;)width(\s+)?:`),
"css-word-break": regexp.MustCompile(`(?i)(^|\s|;)word-break(\s+)?:`),
"css-writing-mode": regexp.MustCompile(`(?i)(^|\s|;)writing-mode(\s+)?:`),
"css-z-index": regexp.MustCompile(`(?i)(^|\s|;)z-index(\s+)?:`),
}
// some CSS tests using regex for things that can't be merged inline
var cssRegexpTests = map[string]*regexp.Regexp{
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`),
"css-at-import": regexp.MustCompile(`(?mi)@import\s`),
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`),
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`),
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`),
"css-pseudo-class-active": regexp.MustCompile(`:active`),
"css-pseudo-class-checked": regexp.MustCompile(`:checked`),
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`),
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`),
"css-pseudo-class-focus": regexp.MustCompile(`:focus`),
"css-pseudo-class-has": regexp.MustCompile(`:has`),
"css-pseudo-class-hover": regexp.MustCompile(`:hover`),
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`),
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`),
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`),
"css-pseudo-class-link": regexp.MustCompile(`:link`),
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`),
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`),
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`),
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`),
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`),
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`),
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`),
"css-pseudo-class-target": regexp.MustCompile(`:target`),
"css-pseudo-class-visited": regexp.MustCompile(`:visited`),
"css-pseudo-element-after": regexp.MustCompile(`:after`),
"css-pseudo-element-before": regexp.MustCompile(`:before`),
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`),
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`),
"css-pseudo-element-marker": regexp.MustCompile(`::marker`),
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`),
}
// some CSS tests using regex for units
var cssRegexpUnitTests = map[string]*regexp.Regexp{
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`),
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`),
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`),
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`),
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`),
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`),
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`),
}

View File

@@ -42,17 +42,15 @@ func runCSSTests(html string) ([]Warning, int, error) {
return results, totalTests, err
}
for key, test := range cssInlineTests {
totalTests++
found := len(doc.Find(test).Nodes)
if found > 0 {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = found
inlineStyleResults := testInlineStyles(doc)
totalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)
for key, count := range inlineStyleResults {
result, err := cie.getTest(key)
if err == nil {
result.Score.Found = count
results = append(results, result)
}
}
// get a list of all generated styles from all nodes
@@ -215,3 +213,39 @@ func isURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// Test the HTML for inline CSS styles and styling attributes
func testInlineStyles(doc *goquery.Document) map[string]int {
matches := make(map[string]int)
// find all elements containing a style attribute
styles := doc.Find("[style]").Nodes
for _, s := range styles {
style, err := tools.GetHTMLAttributeVal(s, "style")
if err != nil {
continue
}
for id, test := range cssInlineRegexTests {
if test.MatchString(style) {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
}
// find all elements containing styleInlineAttributes
for id, test := range styleInlineAttributes {
a := doc.Find(test).Nodes
if len(a) > 0 {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
return matches
}

View File

@@ -0,0 +1,81 @@
package htmlcheck
import (
"fmt"
"sort"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
func TestInlineStyleDetection(t *testing.T) {
/// tests should contain the HTML test, and expected test results in alphabetical order
tests := map[string]string{}
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color: green; transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color:green; transform :rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="TRANSFORM:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="ignore-transform: something">Heading</h1>`] = "" // no match
tests[`<h1 style="text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="text-transform: uppercase; text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="test-transform: uppercase">Heading</h1>`] = "" // no match
tests[`<h1 style="padding-inline-start: 5rem">Heading</h1>`] = "css-padding-inline-start-end"
tests[`<h1 style="margin-inline-end: 5rem">Heading</h1>`] = "css-margin-inline-start-end"
tests[`<h1 style="margin-inline-middle: 5rem">Heading</h1>`] = "" // no match
tests[`<h1 style="color:green!important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green !important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green!important;">Heading</h1>`] = "css-important"
tests[`<h1 style="color:green!important-stuff;">Heading</h1>`] = "" // no match
tests[`<h1 style="background-image:url('img.jpg')">Heading</h1>`] = "css-background-image"
tests[`<h1 style="background-image:url('img.jpg'); color: green">Heading</h1>`] = "css-background-image"
tests[`<h1 style=" color:green; background-image:url('img.jpg');">Heading</h1>`] = "css-background-image"
tests[`<h1 style="display : flex ;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="DISPLAY:FLEX;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="display: flexing;">Heading</h1>`] = "css-display" // should not match css-display-flex rule
tests[`<h1 style="line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)">Heading</h1>`] = "css-line-height,css-opacity,css-unit-calc,css-width"
tests[`<h1 style="color: rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgba(255,255,255, 0);">Heading</h1>`] = "css-rgba"
tests[`<h1 style="border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);">Heading</h1>`] = "css-border,css-rgb,css-rgba"
tests[`<h1 border="2">Heading</h1>`] = "css-border"
tests[`<h1 border="2" background="green">Heading</h1>`] = "css-background,css-border"
tests[`<h1 BORDER="2" BACKGROUND="GREEN">Heading</h1>`] = "css-background,css-border"
tests[`<h1 border-something="2" background-something="green">Heading</h1>`] = "" // no match
tests[`<h1 border="2" style="border: solid green 1px!important">Heading</h1>`] = "css-border,css-important"
for html, expected := range tests {
reader := strings.NewReader(html)
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
t.Log("error ", err)
t.Fail()
}
results := testInlineStyles(doc)
matches := []string{}
uniqMap := make(map[string]bool)
for key := range results {
if _, exists := uniqMap[key]; !exists {
matches = append(matches, key)
}
}
// ensure results are sorted to ensure consistent results
sort.Strings(matches)
assertEqual(t, expected, strings.Join(matches, ","), fmt.Sprintf("inline style detection \"%s\"", html))
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3client"
"github.com/axllent/mailpit/internal/storage"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
var (

View File

@@ -80,7 +80,7 @@ func Run() {
type message struct {
ID string
Size float64
Size uint64
}
func handleClient(conn net.Conn) {
@@ -211,13 +211,13 @@ func handleClient(conn net.Conn) {
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
switch cmd {
case "STAT":
totalSize := float64(0)
totalSize := uint64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
case "LIST":
totalSize := float64(0)
totalSize := uint64(0)
for _, m := range messages {
totalSize += m.Size
}

View File

@@ -0,0 +1,190 @@
// Package prometheus provides Prometheus metrics for Mailpit
package prometheus
import (
"net/http"
"strings"
"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)
}
// UpdateMetrics updates all metrics with current values
func UpdateMetrics() {
info := stats.Load()
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))
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))
}
}
// Returns the Prometheus handler & disables double compression in middleware
func GetHandler() http.Handler {
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
DisableCompression: true,
})
}
// StartUpdater starts the periodic metrics update routine
func StartUpdater() {
InitMetrics()
UpdateMetrics()
// Start periodic updates
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
UpdateMetrics()
}
}()
}
// StartSeparateServer starts a separate HTTP server for Prometheus metrics
func StartSeparateServer() {
StartUpdater()
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{}))
// Create a dedicated server instance
server := &http.Server{
Addr: config.PrometheusListen,
Handler: mux,
}
// Start HTTP server
if err := server.ListenAndServe(); err != nil {
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
}
}
// GetMode returns the Prometheus run mode
func GetMode() string {
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
switch {
case mode == "false", mode == "":
return "disabled"
case mode == "true":
return "integrated"
default:
return "separate"
}
}

View File

@@ -25,27 +25,55 @@ func autoForwardMessage(from string, data *[]byte) {
}
}
func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {
if config.TLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
conn, err := tls.Dial("tcp", addr, tlsConf)
if err != nil {
return nil, fmt.Errorf("TLS dial error: %v", err)
}
client, err := smtp.NewClient(conn, tlsConf.ServerName)
if err != nil {
conn.Close()
return nil, fmt.Errorf("SMTP client error: %v", err)
}
// Note: The caller is responsible for closing the client
return client, nil
}
client, err := smtp.Dial(addr)
if err != nil {
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
}
if config.STARTTLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
if err = client.StartTLS(tlsConf); err != nil {
client.Close()
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
}
}
// Note: The caller is responsible for closing the client
return client, nil
}
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
func forward(from string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
c, err := smtp.Dial(addr)
c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
return err
}
defer c.Close()
if config.SMTPForwardConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPForwardConfig.Host} // #nosec
conf.InsecureSkipVerify = config.SMTPForwardConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
auth := forwardAuthFromConfig()
if auth != nil {

View File

@@ -34,7 +34,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) (string
// SaveToDatabase will attempt to save a message to the database
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (string, error) {
if !config.SMTPStrictRFCHeaders {
if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
@@ -50,32 +50,20 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
// check / set the Return-Path based on SMTP from
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath != from {
if returnPath != "" {
// replace Return-Path
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurrence
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
})
} else {
// add Return-Path
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">")
if err != nil {
return "", err
}
}
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>")
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = shortuuid.New() + "@mailpit"
// add unique ID
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
@@ -108,23 +96,15 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
bccVal := strings.Join(missingAddresses, ", ")
if hasBccHeader {
// email already has Bcc header, add to existing addresses
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurrence
b := msg.Header.Get("Bcc")
bccVal = ", " + b
}
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
})
} else {
// prepend new Bcc header
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
data = append(bcc, data...)
data, err = tools.SetMessageHeader(data, "Bcc", bccVal)
if err != nil {
return "", err
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
@@ -282,10 +262,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
smtpType := "no encryption"
if config.SMTPTLSCert != "" {
if config.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else if config.SMTPRequireTLS {
if config.SMTPRequireTLS {
smtpType = "SSL/TLS required"
} else if config.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else {
smtpType = "STARTTLS optional"
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {

View File

@@ -59,27 +59,55 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
}
}
func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {
if config.TLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
conn, err := tls.Dial("tcp", addr, tlsConf)
if err != nil {
return nil, fmt.Errorf("TLS dial error: %v", err)
}
client, err := smtp.NewClient(conn, tlsConf.ServerName)
if err != nil {
conn.Close()
return nil, fmt.Errorf("SMTP client error: %v", err)
}
// Note: The caller is responsible for closing the client
return client, nil
}
client, err := smtp.Dial(addr)
if err != nil {
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
}
if config.STARTTLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
if err = client.StartTLS(tlsConf); err != nil {
client.Close()
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
}
}
// Note: The caller is responsible for closing the client
return client, nil
}
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Relay(from string, to []string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
c, err := smtp.Dial(addr)
c, err := createRelaySMTPClient(config.SMTPRelayConfig, addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
return err
}
defer c.Close()
if config.SMTPRelayConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host} // #nosec
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
auth := relayAuthFromConfig()
if auth != nil {

View File

@@ -883,7 +883,8 @@ func (s *session) makeEHLOResponse() (response string) {
}
}
response += "250 ENHANCEDSTATUSCODES"
response += "250-ENHANCEDSTATUSCODES\r\n"
response += "250 SMTPUTF8" // last entry must use a space instead of a dash
return
}

View File

@@ -20,10 +20,10 @@ var (
mu sync.RWMutex
smtpAccepted float64
smtpAcceptedSize float64
smtpRejected float64
smtpIgnored float64
smtpAccepted uint64
smtpAcceptedSize uint64
smtpRejected uint64
smtpIgnored uint64
)
// AppInformation struct
@@ -36,29 +36,29 @@ type AppInformation struct {
// Database path
Database string
// Database size in bytes
DatabaseSize float64
DatabaseSize uint64
// Total number of messages in the database
Messages float64
Messages uint64
// Total number of messages in the database
Unread float64
Unread uint64
// Tags and message totals per tag
Tags map[string]int64
// Runtime statistics
RuntimeStats struct {
// Mailpit server uptime in seconds
Uptime float64
Uptime uint64
// Current memory usage in bytes
Memory uint64
// Database runtime messages deleted
MessagesDeleted float64
MessagesDeleted uint64
// Accepted runtime SMTP messages
SMTPAccepted float64
SMTPAccepted uint64
// Total runtime accepted messages size in bytes
SMTPAcceptedSize float64
SMTPAcceptedSize uint64
// Rejected runtime SMTP messages
SMTPRejected float64
SMTPRejected uint64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
SMTPIgnored float64
SMTPIgnored uint64
}
}
@@ -71,7 +71,7 @@ func Load() AppInformation {
runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
info.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds())
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
info.RuntimeStats.SMTPAccepted = smtpAccepted
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
@@ -88,7 +88,7 @@ func Load() AppInformation {
// clear latest version cache after 5 minutes
go func() {
time.Sleep(5 * time.Minute)
time.Sleep(15 * time.Minute)
latestVersionCache = ""
}()
}
@@ -112,7 +112,7 @@ func Track() {
func LogSMTPAccepted(size int) {
mu.Lock()
smtpAccepted = smtpAccepted + 1
smtpAcceptedSize = smtpAcceptedSize + float64(size)
smtpAcceptedSize = smtpAcceptedSize + uint64(size)
mu.Unlock()
}

View File

@@ -32,7 +32,7 @@ func dbCron() {
if total == 0 {
deletedPercent = 100
} else {
deletedPercent = deletedSize * 100 / total
deletedPercent = float64(deletedSize * 100 / total)
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
@@ -56,13 +56,13 @@ func pruneMessages() {
start := time.Now()
ids := []string{}
var prunedSize int64
var size float64
var prunedSize uint64
var size uint64
// prune using `--max` if set
if config.MaxMessages > 0 {
total := CountTotal()
if total > float64(config.MaxAgeInHours) {
if total > uint64(config.MaxAgeInHours) {
offset := config.MaxMessages
if config.DemoMode {
offset = 500
@@ -81,7 +81,7 @@ func pruneMessages() {
return
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
prunedSize = prunedSize + size
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -110,7 +110,7 @@ func pruneMessages() {
if !tools.InArray(id, ids) {
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
prunedSize = prunedSize + size
}
}); err != nil {

View File

@@ -32,7 +32,7 @@ var (
dbLastAction time.Time
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbEncoder *zstd.Encoder
dbDecoder, _ = zstd.NewReader(nil)
temporaryFiles = []string{}
@@ -40,11 +40,31 @@ var (
// InitDB will initialise the database
func InitDB() error {
// dbEncoder
var (
dsn string
err error
)
if config.Compression > 0 {
var compression zstd.EncoderLevel
switch config.Compression {
case 1:
compression = zstd.SpeedFastest
case 2:
compression = zstd.SpeedDefault
case 3:
compression = zstd.SpeedBestCompression
}
dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))
if err != nil {
return err
}
logger.Log().Debugf("[db] storing messages with compression: %s", compression.String())
} else {
logger.Log().Debug("[db] storing messages with no compression")
}
p := config.Database
if p == "" {
@@ -100,8 +120,13 @@ func InitDB() error {
db.SetMaxOpenConns(1)
if sqlDriver == "sqlite" {
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
if config.DisableWAL {
// disable WAL mode for SQLite, allows NFS mounted DBs
_, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;")
} else {
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
}
if err != nil {
return err
}
@@ -185,8 +210,8 @@ func StatsGet() MailboxStats {
}
// CountTotal returns the number of emails in the database
func CountTotal() float64 {
var total float64
func CountTotal() uint64 {
var total uint64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
@@ -196,8 +221,8 @@ func CountTotal() float64 {
}
// CountUnread returns the number of emails in the database that are unread.
func CountUnread() float64 {
var total float64
func CountUnread() uint64 {
var total uint64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
@@ -208,8 +233,8 @@ func CountUnread() float64 {
}
// CountRead returns the number of emails in the database that are read.
func CountRead() float64 {
var total float64
func CountRead() uint64 {
var total uint64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
@@ -220,30 +245,17 @@ func CountRead() float64 {
}
// DbSize returns the size of the SQLite database.
func DbSize() float64 {
var total sql.NullFloat64
func DbSize() uint64 {
var total sql.NullInt64
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return total.Float64
return uint64(total.Int64)
}
return total.Float64
}
// IsUnread returns whether a message is unread or not.
func IsUnread(id string) bool {
var unread int
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id).
QueryRowAndClose(context.TODO(), db)
return unread == 1
return uint64(total.Int64)
}
// MessageIDExists checks whether a Message-ID exists in the DB

View File

@@ -19,7 +19,7 @@ import (
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
"github.com/leporo/sqlf"
"github.com/lithammer/shortuuid/v4"
)
@@ -85,7 +85,7 @@ func Store(body *[]byte) (string, error) {
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := float64(len(*body))
size := uint64(len(*body))
inline := len(env.Inlines)
attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML)
@@ -102,10 +102,25 @@ func Store(body *[]byte) (string, error) {
return "", err
}
// insert compressed raw message
encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
hexStr := hex.EncodeToString(encoded)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
if config.Compression > 0 {
// insert compressed raw message
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
if sqlDriver == "rqlite" {
// rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal
// string and then generate the SQL query, which is more memory intensive, especially with large messages
hexStr := hex.EncodeToString(compressed)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant("mailbox_data"), hexStr), id) // #nosec
} else {
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec
}
compressed = nil
} else {
// insert uncompressed raw message
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant("mailbox_data")), id, string(*body)) // #nosec
}
if err != nil {
return "", err
}
@@ -186,12 +201,12 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created uint64
var id string
var messageID string
var subject string
var metadata string
var size float64
var size uint64
var attachments int
var read int
var snippet string
@@ -279,7 +294,7 @@ func GetMessage(id string) (*Message, error) {
Where(`ID = ?`, id)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created uint64
if err := row.Scan(&created); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -306,7 +321,7 @@ func GetMessage(id string) (*Message, error) {
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: float64(len(raw)),
Size: uint64(len(raw)),
Text: env.Text,
}
@@ -347,7 +362,7 @@ func GetMessage(id string) (*Message, error) {
}
// mark message as read
if err := MarkRead(id); err != nil {
if err := MarkRead([]string{id}); err != nil {
return &obj, err
}
@@ -358,11 +373,12 @@ func GetMessage(id string) (*Message, error) {
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i string
var msg string
var i, msg string
var compressed int
q := sqlf.From(tenant("mailbox_data")).
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Select(`Compressed`).To(&compressed).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
@@ -374,7 +390,7 @@ func GetMessageRaw(id string) ([]byte, error) {
}
var data []byte
if sqlDriver == "rqlite" {
if sqlDriver == "rqlite" && compressed == 1 {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
@@ -383,14 +399,18 @@ func GetMessageRaw(id string) ([]byte, error) {
data = []byte(msg)
}
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
dbLastAction = time.Now()
return raw, err
if compressed == 1 {
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
return raw, err
}
return data, nil
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
@@ -442,7 +462,7 @@ func AttachmentSummary(a *enmime.Part) Attachment {
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = float64(len(a.Content))
o.Size = uint64(len(a.Content))
return o
}
@@ -475,30 +495,28 @@ func LatestID(r *http.Request) (string, error) {
}
// MarkRead will mark a message as read
func MarkRead(id string) error {
if !IsUnread(id) {
return nil
}
func MarkRead(ids []string) error {
for _, id := range ids {
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
}
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
return err
return nil
}
// MarkAllRead will mark all messages as read
@@ -552,32 +570,30 @@ func MarkAllUnread() error {
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
return nil
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)
}
_, 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()
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
return err
return nil
}
// DeleteMessages deletes one or more messages in bulk
@@ -601,11 +617,11 @@ func DeleteMessages(ids []string) error {
defer rows.Close()
toDelete := []string{}
var totalSize float64
var totalSize uint64
for rows.Next() {
var id string
var size float64
var size uint64
if err := rows.Scan(&id, &size); err != nil {
return err
}
@@ -647,7 +663,7 @@ func DeleteMessages(ids []string) error {
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))
addDeletedSize(totalSize)
logMessagesDeleted(len(toDelete))

View File

@@ -22,7 +22,7 @@ func TestTextEmailInserts(t *testing.T) {
}
}
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
@@ -32,7 +32,7 @@ func TestTextEmailInserts(t *testing.T) {
t.Fail()
}
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
@@ -60,7 +60,7 @@ func TestMimeEmailInserts(t *testing.T) {
}
}
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
@@ -70,7 +70,7 @@ func TestMimeEmailInserts(t *testing.T) {
t.Fail()
}
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
@@ -79,56 +79,64 @@ func TestMimeEmailInserts(t *testing.T) {
}
func TestRetrieveMimeEmail(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
compressionLevels := []int{0, 1, 2, 3}
setup(tenantID)
for _, compressionLevel := range compressionLevels {
t.Logf("Testing compression level: %d", compressionLevel)
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
config.Compression = compressionLevel
setup(tenantID)
if tenantID == "" {
t.Log("Testing mime email retrieval")
} else {
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
if tenantID == "" {
t.Log("Testing mime email retrieval")
} else {
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
}
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, uint64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
Close()
}
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
Close()
}
// reset compression
config.Compression = 1
}
func TestMessageSummary(t *testing.T) {

View File

@@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
time.Sleep(250 * time.Millisecond)
bcStatsDelay = false
b := struct {
Total float64
Unread float64
Total uint64
Unread uint64
Version string
}{
Total: CountTotal(),

View File

@@ -11,7 +11,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
"github.com/leporo/sqlf"
)

View File

@@ -0,0 +1,5 @@
-- CREATE Compressed COLUMN IN mailbox_data
ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';
-- SET Compressed = 1 for all existing data
UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1;

View File

@@ -39,12 +39,12 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created uint64
var id string
var messageID string
var subject string
var metadata string
var size float64
var size uint64
var attachments int
var snippet string
var read int
@@ -100,6 +100,39 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
return results, nrResults, err
}
// SearchUnreadCount returns the number of unread messages matching a search.
// This is run one at a time to allow connected browsers to be updated.
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
tsStart := time.Now()
q := searchQueryBuilder(search, timezone)
if beforeTS > 0 {
q = q.Where(`Created < ?`, beforeTS)
}
var unread int64
q = q.Where("Read = 0").Select(`COUNT(*)`)
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var ignore sql.NullString
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
})
dbLastAction = time.Now()
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", unread, search, elapsed)
return unread, err
}
// DeleteSearch will delete all messages for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
@@ -108,15 +141,15 @@ func DeleteSearch(search, timezone string) error {
q := searchQueryBuilder(search, timezone)
ids := []string{}
deleteSize := float64(0)
deleteSize := uint64(0)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created uint64
var id string
var messageID string
var subject string
var metadata string
var size float64
var size uint64
var attachments int
var read int
var snippet string
@@ -214,7 +247,7 @@ func DeleteSearch(search, timezone string) error {
}
}
addDeletedSize(int64(deleteSize))
addDeletedSize(deleteSize)
logMessagesDeleted(total)
@@ -224,6 +257,47 @@ func DeleteSearch(search, timezone string) error {
return nil
}
// SetSearchReadStatus marks all messages matching the search as read or unread
func SetSearchReadStatus(search, timezone string, read bool) error {
q := searchQueryBuilder(search, timezone).Where("Read = ?", !read)
ids := []string{}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created uint64
var id string
var messageID string
var subject string
var metadata string
var size uint64
var attachments int
var read int
var snippet string
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
}); err != nil {
return err
}
if read {
if err := MarkRead(ids); err != nil {
return err
}
} else {
if err := MarkUnread(ids); err != nil {
return err
}
}
return nil
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes
@@ -265,8 +339,8 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
lw = lw[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
// ignore blank searches
if len(w) == 0 {
continue
}
@@ -445,7 +519,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
//
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
// M, m, Mb, MB and mb are treated as Megabytes.
func sizeToBytes(v string) int64 {
func sizeToBytes(v string) uint64 {
v = strings.ToLower(v)
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
@@ -463,15 +537,15 @@ func sizeToBytes(v string) int64 {
}
if unit == "" {
return int64(i)
return uint64(i)
}
if unit == "k" || unit == "kb" {
return int64(i * 1024)
return uint64(i * 1024)
}
if unit == "m" || unit == "mb" {
return int64(i * 1024 * 1024)
return uint64(i * 1024 * 1024)
}
return 0

View File

@@ -7,7 +7,7 @@ import (
"testing"
"github.com/axllent/mailpit/config"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
func TestSearch(t *testing.T) {
@@ -203,7 +203,7 @@ func TestEscPercentChar(t *testing.T) {
}
func TestSizeToBytes(t *testing.T) {
tests := map[string]int64{}
tests := map[string]uint64{}
tests["1m"] = 1048576
tests["1mb"] = 1048576
tests["1 M"] = 1048576

View File

@@ -35,8 +35,8 @@ func SettingPut(k, v string) error {
}
// The total deleted message size as an int64 value
func getDeletedSize() float64 {
var result sql.NullFloat64
func getDeletedSize() uint64 {
var result sql.NullInt64
err := sqlf.From(tenant("settings")).
Select("Value").To(&result).
Where("Key = ?", "DeletedSize").
@@ -47,12 +47,12 @@ func getDeletedSize() float64 {
return 0
}
return result.Float64
return uint64(result.Int64)
}
// The total raw non-compressed messages size in bytes of all messages in the database
func totalMessagesSize() float64 {
var result sql.NullFloat64
func totalMessagesSize() uint64 {
var result sql.NullInt64
err := sqlf.From(tenant("mailbox")).
Select("SUM(Size)").To(&result).
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
@@ -61,11 +61,11 @@ func totalMessagesSize() float64 {
return 0
}
return result.Float64
return uint64(result.Int64)
}
// AddDeletedSize will add the value to the DeletedSize setting
func addDeletedSize(v int64) {
func addDeletedSize(v uint64) {
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}

View File

@@ -28,9 +28,9 @@ type Message struct {
// Message subject
Subject string
// List-Unsubscribe header information
// swagger:ignore
ListUnsubscribe ListUnsubscribe
// Message date if set, else date received
// Message RFC3339Nano date & time (if set), else date & time received
// ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
Date time.Time
// Message tags
Tags []string
@@ -39,7 +39,7 @@ type Message struct {
// Message body HTML
HTML string
// Message size in bytes
Size float64
Size uint64
// Inline message attachments
Inline []Attachment
// Message attachments
@@ -59,7 +59,7 @@ type Attachment struct {
// Content ID
ContentID string
// Size in bytes
Size float64
Size uint64
}
// MessageSummary struct for frontend messages
@@ -84,12 +84,12 @@ type MessageSummary struct {
ReplyTo []*mail.Address
// Email subject
Subject string
// Created time
// Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size float64
Size uint64
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
@@ -98,8 +98,8 @@ type MessageSummary struct {
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total float64
Unread float64
Total uint64
Unread uint64
Tags []string
}
@@ -117,10 +117,10 @@ type DBMailSummary struct {
type ListUnsubscribe struct {
// List-Unsubscribe header value
Header string
// Detected links, maximum one email and one HTTP(S)
// Detected links, maximum one email and one HTTP(S) link
Links []string
// Validation errors if any
// Validation errors (if any)
Errors string
// List-Unsubscribe-Post value if set
// List-Unsubscribe-Post value (if set)
HeaderPost string
}

View File

@@ -59,11 +59,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if float64(total) != s.Total {
if uint64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
}
if float64(unread) != s.Unread {
if uint64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
}
}

View File

@@ -9,14 +9,14 @@ import (
"github.com/axllent/mailpit/internal/html2text"
"github.com/axllent/mailpit/internal/logger"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
var (
// for stats to prevent import cycle
mu sync.RWMutex
// StatsDeleted for counting the number of messages deleted
StatsDeleted float64
StatsDeleted uint64
)
// AddTempFile adds a file to the slice of files to delete on exit
@@ -88,7 +88,7 @@ func cleanString(str string) string {
// LogMessagesDeleted logs the number of messages deleted
func logMessagesDeleted(n int) {
mu.Lock()
StatsDeleted = StatsDeleted + float64(n)
StatsDeleted = StatsDeleted + uint64(n)
mu.Unlock()
}

View File

@@ -58,8 +58,10 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
return msg, nil
}
// UpdateMessageHeader scans a message for a header and updates its value if found.
func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
// SetMessageHeader scans a message for a header and updates its value if found.
// It does not consider multiple instances of the same header.
// If not found it will add the header to the beginning of the message.
func SetMessageHeader(msg []byte, header, value string) ([]byte, error) {
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
@@ -90,13 +92,11 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
}
}
if len(hdr) > 0 {
logger.Log().Debugf("[relay] replaced %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
}
return bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1), nil
}
return msg, nil
// no header, so add one to beginning
return append([]byte(header+": "+value+"\r\n"), msg...), nil
}
// OverrideFromHeader scans a message for the From header and replaces it with a different email address.

26
main.go
View File

@@ -1,3 +1,4 @@
// Package main is the entrypoint
package main
import (
@@ -10,26 +11,11 @@ import (
)
func main() {
exec, err := os.Executable()
if err != nil {
panic(err)
}
// running directly
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) ||
!strings.Contains(filepath.Base(os.Args[0]), "sendmail") {
cmd.Execute()
} else {
// symlinked as "*sendmail*"
// if the command executable contains "send" in the name (eg: sendmail), then run the sendmail command
if strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), "send") {
sendmail.Run()
} else {
// else run mailpit
cmd.Execute()
}
}
// Normalize returns a lowercase string stripped of the file extension (if exists).
// Used for detecting Windows commands which ignores letter casing and `.exe`.
// eg: "MaIlpIT.Exe" returns "mailpit"
func normalize(s string) string {
s = strings.ToLower(s)
return strings.TrimSuffix(s, filepath.Ext(s))
}

1362
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,10 @@
"color-hash": "^2.0.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
"highlight.js": "^11.11.1",
"ical.js": "^2.0.1",
"mitt": "^3.0.1",
"modern-screenshot": "^4.4.30",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"timezones-list": "^3.0.3",
"vue": "^3.2.13",

View File

@@ -75,6 +75,9 @@ type webUIConfiguration struct {
// Whether messages with duplicate IDs are ignored
DuplicatesIgnored bool
// Whether the delete button should be hidden
HideDeleteAllButton bool
}
// Web UI configuration response
@@ -121,6 +124,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
conf.SpamAssassin = config.EnableSpamAssassin != ""
conf.ChaosEnabled = chaos.Enabled
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
conf.HideDeleteAllButton = config.HideDeleteAllButton
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(conf); err != nil {

View File

@@ -40,18 +40,21 @@ type messagesSummaryResponse struct {
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total float64 `json:"total"`
Total uint64 `json:"total"`
// Total number of unread messages in mailbox
Unread float64 `json:"unread"`
Unread uint64 `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count float64 `json:"count"`
Count uint64 `json:"count"`
// Total number of messages matching current query
MessagesCount float64 `json:"messages_count"`
MessagesCount uint64 `json:"messages_count"`
// Total number of unread messages matching current query
MessagesUnreadCount uint64 `json:"messages_unread"`
// Pagination offset
Start int `json:"start"`
@@ -95,11 +98,12 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Count = uint64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
res.MessagesCount = stats.Total
res.MessagesUnreadCount = stats.Unread
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
@@ -118,22 +122,36 @@ type setReadStatusParams struct {
// example: true
Read bool
// Array of message database IDs
// Optional array of message database IDs
//
// required: false
// default: []
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
IDs []string
// Optional messages matching a search
//
// required: false
// example: tag:backups
Search string
}
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
//
// in: query
// required: false
// type string
TZ string `json:"tz"`
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
// You can optionally provide an array of IDs or a search string.
// If neither IDs nor search is provided then all mailbox messages are updated.
//
// Consumes:
// - application/json
@@ -150,8 +168,9 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
Read bool
IDs []string
Search string
}
err := decoder.Decode(&data)
@@ -161,8 +180,20 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
}
ids := data.IDs
search := data.Search
if len(ids) == 0 {
if len(ids) > 0 && search != "" {
httpError(w, "You may specify either IDs or a search query, not both")
return
}
if search != "" {
err := storage.SetSearchReadStatus(search, r.URL.Query().Get("tz"), data.Read)
if err != nil {
httpError(w, err.Error())
return
}
} else if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
@@ -178,18 +209,14 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
if err := storage.MarkRead(ids); err != nil {
httpError(w, err.Error())
return
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
if err := storage.MarkUnread(ids); err != nil {
httpError(w, err.Error())
return
}
}
}
@@ -265,6 +292,7 @@ type searchParams struct {
//
// in: query
// required: false
// default: 0
// type integer
Start string `json:"start"`
@@ -272,10 +300,11 @@ type searchParams struct {
//
// in: query
// required: false
// default: 50
// type integer
Limit string `json:"limit"`
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
//
// in: query
// required: false
@@ -320,12 +349,20 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = float64(results)
res.Count = uint64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = uint64(results)
res.Unread = stats.Unread
res.Tags = stats.Tags
unread, err := storage.SearchUnreadCount(search, r.URL.Query().Get("tz"), beforeTS)
if err != nil {
httpError(w, err.Error())
return
}
res.MessagesUnreadCount = uint64(unread)
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())

View File

@@ -12,7 +12,7 @@ import (
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
// swagger:parameters HTMLCheckParams

View File

@@ -133,7 +133,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
httpError(w, "Failed: unable to parse From header: "+err.Error())
return
}
@@ -170,7 +170,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
msg, err = tools.SetMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
@@ -178,8 +178,8 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
// update Message-ID with unique ID
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return

View File

@@ -14,7 +14,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
// swagger:parameters SendMessageParams

View File

@@ -13,7 +13,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
"github.com/kovidgoyal/imaging"
)

View File

@@ -19,6 +19,7 @@ import (
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
@@ -158,7 +159,7 @@ func apiRoutes() *mux.Router {
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", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
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")
@@ -182,6 +183,13 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
// 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) {
prometheus.GetHandler().ServeHTTP(w, r)
})).Methods("GET")
}
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
@@ -198,6 +206,48 @@ func basicAuthResponse(w http.ResponseWriter) {
_, _ = w.Write([]byte("Unauthorised.\n"))
}
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
// It can use dedicated send API authentication or accept any credentials based on configuration
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 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)
return
}
// If Send API credentials are configured, only accept those credentials
if auth.SendAPICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.SendAPICredentials.Match(user, pass) {
basicAuthResponse(w)
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)
return
}
// No Send API credentials configured - fall back to UI auth
middleWareFunc(fn)(w, r)
}
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
@@ -239,7 +289,9 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
// Check basic authentication headers if configured.
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight.
if !(AccessControlAllowOrigin != "" && r.Method == http.MethodOptions) && auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -253,7 +305,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
if config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r)
return
}

View File

@@ -13,10 +13,12 @@ import (
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/apiv1"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
"golang.org/x/crypto/bcrypt"
)
var (
@@ -24,6 +26,18 @@ var (
Read bool
IDs []string
}
// Shared test message structure for consistency
testSendMessage = map[string]interface{}{
"From": map[string]string{
"Email": "test@example.com",
},
"To": []map[string]string{
{"Email": "recipient@example.com"},
},
"Subject": "Test",
"Text": "Test message",
}
)
func TestAPIv1Messages(t *testing.T) {
@@ -312,6 +326,157 @@ func TestAPIv1Send(t *testing.T) {
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
func TestSendAPIAuthMiddleware(t *testing.T) {
setup()
defer storage.Close()
// Test 1: Send API with accept-any enabled (should bypass all auth)
t.Run("SendAPIAuthAcceptAny", func(t *testing.T) {
// Set up UI auth and enable accept-any for send API
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
originalUICredentials := auth.UICredentials
defer func() {
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
auth.UICredentials = originalUICredentials
}()
// Enable accept-any for send API
config.SendAPIAuthAcceptAny = true
// Set up UI auth that would normally block requests
testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
auth.SetUIAuth("testuser:" + string(testHash))
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
// Should succeed without any auth headers
jsonData, _ := json.Marshal(testSendMessage)
_, err := clientPost(ts.URL+"/api/v1/send", string(jsonData))
if err != nil {
t.Errorf("Expected send to succeed with accept-any, got error: %s", err.Error())
}
})
// Test 2: Send API with dedicated credentials
t.Run("SendAPIWithDedicatedCredentials", func(t *testing.T) {
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
originalUICredentials := auth.UICredentials
originalSendAPICredentials := auth.SendAPICredentials
defer func() {
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
auth.UICredentials = originalUICredentials
auth.SendAPICredentials = originalSendAPICredentials
}()
config.SendAPIAuthAcceptAny = false
// Set up UI auth
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
auth.SetUIAuth("uiuser:" + string(uiHash))
// Set up dedicated Send API auth
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
auth.SetSendAPIAuth("senduser:" + string(sendHash))
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
jsonData, _ := json.Marshal(testSendMessage)
// Should succeed with correct Send API credentials
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "sendpass")
if err != nil {
t.Errorf("Expected send to succeed with correct Send API credentials, got error: %s", err.Error())
}
// Should fail with wrong Send API credentials
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "wrongpass")
if err == nil {
t.Error("Expected send to fail with wrong Send API credentials")
}
// Should fail with UI credentials when Send API credentials are set
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
if err == nil {
t.Error("Expected send to fail with UI credentials when Send API credentials are required")
}
})
// Test 3: Send API fallback to UI auth when no Send API auth is configured
t.Run("SendAPIFallbackToUIAuth", func(t *testing.T) {
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
originalUICredentials := auth.UICredentials
originalSendAPICredentials := auth.SendAPICredentials
defer func() {
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
auth.UICredentials = originalUICredentials
auth.SendAPICredentials = originalSendAPICredentials
}()
config.SendAPIAuthAcceptAny = false
auth.SendAPICredentials = nil
// Set up only UI auth
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
auth.SetUIAuth("uiuser:" + string(uiHash))
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
jsonData, _ := json.Marshal(testSendMessage)
// Should succeed with UI credentials when no Send API auth is configured
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
if err != nil {
t.Errorf("Expected send to succeed with UI credentials when no Send API auth configured, got error: %s", err.Error())
}
// Should fail without any credentials
_, err = clientPost(ts.URL+"/api/v1/send", string(jsonData))
if err == nil {
t.Error("Expected send to fail without credentials when UI auth is required")
}
})
// Test 4: Regular API endpoints should not be affected by Send API auth settings
t.Run("RegularAPINotAffectedBySendAPIAuth", func(t *testing.T) {
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
originalUICredentials := auth.UICredentials
originalSendAPICredentials := auth.SendAPICredentials
defer func() {
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
auth.UICredentials = originalUICredentials
auth.SendAPICredentials = originalSendAPICredentials
}()
// Set up UI auth and Send API auth
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
auth.SetUIAuth("uiuser:" + string(uiHash))
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
auth.SetSendAPIAuth("senduser:" + string(sendHash))
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
// Regular API endpoint should require UI credentials, not Send API credentials
_, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "uiuser", "uipass")
if err != nil {
t.Errorf("Expected regular API to work with UI credentials, got error: %s", err.Error())
}
// Regular API endpoint should fail with Send API credentials
_, err = clientGetWithAuth(ts.URL+"/api/v1/messages", "senduser", "sendpass")
if err == nil {
t.Error("Expected regular API to fail with Send API credentials")
}
})
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
@@ -340,8 +505,8 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
return
}
assertEqual(t, float64(unread), m.Unread, "wrong unread count")
assertEqual(t, float64(total), m.Total, "wrong total count")
assertEqual(t, uint64(unread), m.Unread, "wrong unread count")
assertEqual(t, uint64(total), m.Total, "wrong total count")
}
func assertSearchEqual(t *testing.T, uri, query string, count int) {
@@ -361,7 +526,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
return
}
assertEqual(t, float64(count), m.MessagesCount, "wrong search results count")
assertEqual(t, uint64(count), m.MessagesCount, "wrong search results count")
}
func insertEmailData(t *testing.T) {
@@ -521,6 +686,59 @@ func clientPost(url, body string) ([]byte, error) {
return data, err
}
func clientPostWithAuth(url, body, username, password string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("POST", url, b)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
return data, err
}
func clientGetWithAuth(url, username, password string) ([]byte, error) {
client := new(http.Client)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
return data, err
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return

View File

@@ -1,6 +1,7 @@
<script>
import CommonMixins from './mixins/CommonMixins'
import Favicon from './components/Favicon.vue'
import AppBadge from './components/AppBadge.vue'
import Notifications from './components/Notifications.vue'
import EditTags from './components/EditTags.vue'
import { mailbox } from "./stores/mailbox"
@@ -10,6 +11,7 @@ export default {
components: {
Favicon,
AppBadge,
Notifications,
EditTags
},
@@ -40,6 +42,7 @@ export default {
<template>
<RouterView />
<Favicon />
<AppBadge />
<Notifications />
<EditTags />
</template>

View File

@@ -15,7 +15,9 @@ $font-family-sans-serif:
$link-decoration: none;
$primary: #2c3e50;
$secondary: #495057;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;
$body-color-dark: #e7eaed;
$offcanvas-border-width: 0;
$body-color: #080808;

View File

@@ -89,6 +89,14 @@
.token.property {
color: #ee6969;
}
.btn-outline-secondary {
color: #9c9c9c;
&:hover {
color: $body-color-dark;
}
}
}
.text-spaces-nowrap {
@@ -228,27 +236,24 @@
}
}
#message-page {
#message-page,
#MessageList {
.list-group-item.message:first-child {
border-top: 0;
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
.message:not(.active) {
b {
color: $list-group-color;
}
&.read {
color: $text-muted;
> div {
opacity: 0.5;
}
b {
color: $list-group-color;
}
@@ -354,12 +359,19 @@ body.blur {
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
// HighlightJS for HTML rendering
@import "highlight.js/styles/github.css";
@include color-mode(dark) {
@import "highlight.js/scss/github-dark";
.hljs {
background: transparent;
}
}
code[class*="language-"],
pre[class*="language-"] {
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
@@ -408,72 +420,6 @@ pre[class*="language-"] {
white-space: normal;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.function-name,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.function,
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
}
.token.bold {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
@@ -481,24 +427,3 @@ pre[class*="language-"] {
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}

View File

@@ -130,7 +130,7 @@ export default {
<div class="col-6">
<div class="card border-secondary text-center">
<div class="card-header">Database size</div>
<div class="card-body text-secondary">
<div class="card-body text-muted">
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
</h5>
</div>
@@ -139,7 +139,7 @@ export default {
<div class="col-6">
<div class="card border-secondary text-center">
<div class="card-header">RAM usage</div>
<div class="card-body text-secondary">
<div class="card-body text-muted">
<h5 class="card-title">
{{ getFileSize(mailbox.appInfo.RuntimeStats.Memory) }}
</h5>
@@ -157,7 +157,7 @@ export default {
Refresh
</button>
</div>
<div class="card-body text-secondary">
<div class="card-body text-muted">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
@@ -182,7 +182,7 @@ export default {
</td>
<td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
<small class="text-muted">
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})

View File

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

View File

@@ -0,0 +1,59 @@
<script>
import { mailbox } from '../stores/mailbox.js'
export default {
data() {
return {
updating: false,
needsUpdate: false,
timeout: 500,
}
},
computed: {
mailboxUnread() {
return mailbox.unread
}
},
watch: {
mailboxUnread: {
handler() {
if (this.updating) {
this.needsUpdate = true
return
}
this.scheduleUpdate()
},
immediate: true
}
},
methods: {
scheduleUpdate() {
this.updating = true
this.needsUpdate = false
window.setTimeout(() => {
this.updateAppBadge()
this.updating = false
if (this.needsUpdate) {
this.scheduleUpdate()
}
}, this.timeout)
},
updateAppBadge() {
if (!('setAppBadge' in navigator)) {
return
}
navigator.setAppBadge(this.mailboxUnread)
}
}
}
</script>
<template></template>

View File

@@ -176,7 +176,7 @@ export default {
</template>
<template v-else>
<p class="text-center mt-5">
<span v-if="loadingMessages > 0" class="text-secondary">
<span v-if="loadingMessages > 0" class="text-muted">
Loading messages...
</span>
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>

View File

@@ -66,7 +66,7 @@ export default {
<template>
<template v-if="!modals">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
<div class="text-truncate fw-normal">
<div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }}
</div>
</div>
@@ -84,26 +84,28 @@ export default {
<template v-if="!mailbox.selected.length">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
:disabled="!mailbox.unread" @click="markAllRead">
:disabled="!mailbox.messages_unread" @click="markAllRead">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
:disabled="!mailbox.total" @click="deleteAllMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
:disabled="!mailbox.total" @click="deleteAllMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
</template>
<NavSelected @loadMessages="loadMessages" />

View File

@@ -41,11 +41,32 @@ export default {
return
}
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, (response) => {
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
uri += '&tz=' + encodeURIComponent(mailbox.timeZone)
}
this.delete(uri, false, () => {
this.$router.push('/')
})
}
},
markAllRead() {
const s = this.getSearch()
if (!s) {
return
}
let uri = this.resolve(`/api/v1/messages`)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
}
this.put(uri, { 'read': true, "search": s }, () => {
window.scrollInPlace = true
this.loadMessages()
})
},
}
}
</script>
@@ -53,7 +74,7 @@ export default {
<template>
<template v-if="!modals">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
<div class="text-truncate fw-normal">
<div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }}
</div>
</div>
@@ -69,15 +90,28 @@ export default {
</RouterLink>
<template v-if="!mailbox.selected.length">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
@click="deleteAllMessages" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
:disabled="!mailbox.messages_unread" @click="markAllRead">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.messages_unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
@click="deleteAllMessages" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
</template>
<NavSelected @loadMessages="loadMessages" />
@@ -86,6 +120,29 @@ export default {
<template v-else>
<!-- Modals -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all search results as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(mailbox.messages_unread) }}
message<span v-if="mailbox.messages_unread > 1">s</span>
matching <code>{{ getSearch() }}</code>
as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markAllRead">Confirm</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
aria-hidden="true">
<div class="modal-dialog">

View File

@@ -169,8 +169,11 @@ export default {
<input class="form-check-input" type="checkbox" role="switch"
id="skip-confirmations" v-model="mailbox.skipConfirmations">
<label class="form-check-label" for="skip-confirmations">
Skip <code>Delete all</code> &amp; <code>Mark all read</code> confirmation
dialogs
Skip
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
<code>Delete all</code> &amp;
</template>
<code>Mark all read</code> confirmation dialogs
</label>
</div>
</div>

View File

@@ -323,7 +323,7 @@ export default {
{{ round2dm(summary.Total.Unsupported) }}% not supported
</span>
</p>
<p class="small text-secondary">
<p class="small text-muted">
calculated from {{ formatNumber(check.Total.Tests) }} tests
</p>
</template>
@@ -343,7 +343,7 @@ export default {
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
:aria-label="p" :id="'Check_' + k">
<label class="form-check-label" :for="'Check_' + k"
:class="platforms.indexOf(k) !== -1 ? '' : 'text-secondary'" :title="families(k).join(', ')"
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'" :title="families(k).join(', ')"
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
{{ p }}
</label>
@@ -451,7 +451,7 @@ export default {
</div>
</div>
<p class="text-center text-secondary small mt-4">
<p class="text-center text-muted small mt-4">
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
</p>

View File

@@ -194,7 +194,7 @@ export default {
</div>
<div v-if="!check">
<p class="text-secondary">
<p class="text-muted">
Link check scans your email text &amp; HTML for unique links, testing the response status codes.
This includes links to images and remote CSS stylesheets.
</p>
@@ -219,7 +219,7 @@ export default {
<div class="card mb-3">
<div class="card-header h4" :class="s.Class">
Status {{ s.StatusCode }}
<small v-if="s.Status != ''" class="ms-2 small text-secondary">({{ s.Status }})</small>
<small v-if="s.Status != ''" class="ms-2 small text-muted">({{ s.Status }})</small>
</div>
<ul class="list-group list-group-flush">
<li v-for="u in s.URLS" class="list-group-item">

View File

@@ -4,12 +4,15 @@ import Headers from './Headers.vue'
import HTMLCheck from './HTMLCheck.vue'
import LinkCheck from './LinkCheck.vue'
import SpamAssassin from './SpamAssassin.vue'
import Prism from 'prismjs'
import Tags from 'bootstrap5-tags'
import { Tooltip } from 'bootstrap'
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import xml from 'highlight.js/lib/languages/xml'
hljs.registerLanguage('html', xml)
export default {
props: {
@@ -203,10 +206,8 @@ export default {
}
}, 500)
// html highlighting
window.Prism = window.Prism || {}
window.Prism.manual = true
Prism.highlightAll()
// HTML highlighting
hljs.highlightAll()
},
resizeIframe(el) {
@@ -437,7 +438,7 @@ export default {
:class="showUnsubscribe ? '' : 'd-none'">
<th>Unsubscribe</th>
<td>
<span v-if="message.ListUnsubscribe.Links.length" class="text-secondary small me-2">
<span v-if="message.ListUnsubscribe.Links.length" class="text-muted small me-2">
<template v-for="(u, i) in message.ListUnsubscribe.Links">
<template v-if="i > 0">, </template>
&lt;{{ u }}&gt;
@@ -608,7 +609,7 @@ export default {
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
tabindex="0" :class="message.HTML == '' ? 'show' : ''">

View File

@@ -57,6 +57,7 @@ export default {
mailbox.tags = response.data.tags // all tags
mailbox.messages = response.data.messages // current messages
mailbox.count = response.data.messages_count // total results for this mailbox/search
mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search
// ensure the pagination remains consistent
pagination.start = response.data.start

View File

@@ -203,8 +203,7 @@ export default {
</div>
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
</div>
@@ -215,7 +214,7 @@ export default {
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
</div>

View File

@@ -570,7 +570,7 @@ export default {
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
<div class="text-truncate fw-normal">
<div class="text-truncate fw-normal" style="line-height: 1rem">
{{ mailbox.uiConfig.Label }}
</div>
</div>
@@ -599,7 +599,7 @@ export default {
<div class="list-group">
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
:id="message.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action"
class="row gx-1 message d-flex small list-group-item list-group-item-action message"
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
<div class="col overflow-x-hidden">
<div class="text-truncate privacy small">

View File

@@ -154,7 +154,7 @@ export default {
</div>
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @loadMessages="loadMessages" />
<NavTags />
</div>
@@ -165,7 +165,7 @@ export default {
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @loadMessages="loadMessages" />
<NavTags />
</div>

View File

@@ -548,7 +548,7 @@
}
},
"put": {
"description": "If no IDs are provided then all messages are updated.",
"description": "You can optionally provide an array of IDs or a search string.\nIf neither IDs nor search is provided then all mailbox messages are updated.",
"consumes": [
"application/json"
],
@@ -572,8 +572,9 @@
"type": "object",
"properties": {
"IDs": {
"description": "Array of message database IDs",
"description": "Optional array of message database IDs",
"type": "array",
"default": [],
"items": {
"type": "string"
},
@@ -587,9 +588,21 @@
"type": "boolean",
"default": false,
"example": true
},
"Search": {
"description": "Optional messages matching a search",
"type": "string",
"example": "tag:backups"
}
}
}
},
{
"type": "string",
"x-go-name": "TZ",
"description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
"name": "tz",
"in": "query"
}
],
"responses": {
@@ -677,6 +690,7 @@
},
{
"type": "string",
"default": "0",
"x-go-name": "Start",
"description": "Pagination offset",
"name": "start",
@@ -684,6 +698,7 @@
},
{
"type": "string",
"default": "50",
"x-go-name": "Limit",
"description": "Limit results",
"name": "limit",
@@ -692,7 +707,7 @@
{
"type": "string",
"x-go-name": "TZ",
"description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
"description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
"name": "tz",
"in": "query"
}
@@ -1087,8 +1102,8 @@
},
"DatabaseSize": {
"description": "Database size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"LatestVersion": {
"description": "Latest Mailpit version",
@@ -1096,8 +1111,8 @@
},
"Messages": {
"description": "Total number of messages in the database",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"RuntimeStats": {
"description": "Runtime statistics",
@@ -1110,33 +1125,33 @@
},
"MessagesDeleted": {
"description": "Database runtime messages deleted",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPAccepted": {
"description": "Accepted runtime SMTP messages",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPAcceptedSize": {
"description": "Total runtime accepted messages size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPIgnored": {
"description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPRejected": {
"description": "Rejected runtime SMTP messages",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Uptime": {
"description": "Mailpit server uptime in seconds",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
}
}
},
@@ -1150,8 +1165,8 @@
},
"Unread": {
"description": "Total number of messages in the database",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Version": {
"description": "Current Mailpit version",
@@ -1182,8 +1197,8 @@
},
"Size": {
"description": "Size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
@@ -1421,6 +1436,32 @@
"x-go-name": "Response",
"x-go-package": "github.com/axllent/mailpit/internal/linkcheck"
},
"ListUnsubscribe": {
"description": "ListUnsubscribe contains a summary of List-Unsubscribe \u0026 List-Unsubscribe-Post headers\nincluding validation of the link structure",
"type": "object",
"properties": {
"Errors": {
"description": "Validation errors (if any)",
"type": "string"
},
"Header": {
"description": "List-Unsubscribe header value",
"type": "string"
},
"HeaderPost": {
"description": "List-Unsubscribe-Post value (if set)",
"type": "string"
},
"Links": {
"description": "Detected links, maximum one email and one HTTP(S) link",
"type": "array",
"items": {
"type": "string"
}
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
},
"Message": {
"description": "Message data excluding physical attachments",
"type": "object",
@@ -1447,7 +1488,7 @@
}
},
"Date": {
"description": "Message date if set, else date received",
"description": "Message RFC3339Nano date \u0026 time (if set), else date \u0026 time received\n([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)",
"type": "string",
"format": "date-time"
},
@@ -1469,6 +1510,9 @@
"$ref": "#/definitions/Attachment"
}
},
"ListUnsubscribe": {
"$ref": "#/definitions/ListUnsubscribe"
},
"MessageID": {
"description": "Message ID",
"type": "string"
@@ -1486,8 +1530,8 @@
},
"Size": {
"description": "Message size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Subject": {
"description": "Message subject",
@@ -1550,7 +1594,7 @@
}
},
"Created": {
"description": "Created time",
"description": "Received RFC3339Nano date \u0026 time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)",
"type": "string",
"format": "date-time"
},
@@ -1578,8 +1622,8 @@
},
"Size": {
"description": "Message size in bytes (total)",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Snippet": {
"description": "Message snippet includes up to 250 characters",
@@ -1620,10 +1664,16 @@
},
"messages_count": {
"description": "Total number of messages matching current query",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "MessagesCount"
},
"messages_unread": {
"description": "Total number of unread messages matching current query",
"type": "integer",
"format": "uint64",
"x-go-name": "MessagesUnreadCount"
},
"start": {
"description": "Pagination offset",
"type": "integer",
@@ -1640,14 +1690,14 @@
},
"total": {
"description": "Total number of messages in mailbox",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "Total"
},
"unread": {
"description": "Total number of unread messages in mailbox",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "Unread"
}
},
@@ -1938,6 +1988,10 @@
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"HideDeleteAllButton": {
"description": "Whether the delete button should be hidden",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"