mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-06 11:27:01 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f4a10d89 | ||
|
|
8f7bf25022 | ||
|
|
a1110e5ad8 | ||
|
|
31a2ed8824 | ||
|
|
f675ef7b5e | ||
|
|
4257a89584 | ||
|
|
52957cd81f | ||
|
|
1520143c45 | ||
|
|
5107ce0191 | ||
|
|
40afef8ffd | ||
|
|
fed20de522 | ||
|
|
6999b2ea02 | ||
|
|
72e92d2d1e | ||
|
|
803adf29ac | ||
|
|
fb0230a460 | ||
|
|
873193bcec | ||
|
|
e3538cb86a | ||
|
|
e6ab9e1008 | ||
|
|
86f3546bfe | ||
|
|
a6b5f5f76b | ||
|
|
82d7bdc971 | ||
|
|
020d5b0fcb | ||
|
|
f2b91ac9d5 | ||
|
|
4dff7adc1d | ||
|
|
9bfdeb5f7b | ||
|
|
c5b3edf87d | ||
|
|
8c59229f97 | ||
|
|
56739ceac2 | ||
|
|
5240b1b33e | ||
|
|
8f80a57c3c | ||
|
|
04ea905619 | ||
|
|
b84b428434 | ||
|
|
91409310d7 | ||
|
|
99a3e17243 | ||
|
|
ff272d1c5e | ||
|
|
74c6a0a434 | ||
|
|
e16267ab50 | ||
|
|
8d86b39385 | ||
|
|
38914348a5 | ||
|
|
25580b9a68 | ||
|
|
a1c2690c44 | ||
|
|
ff8b6326ab | ||
|
|
5d2966d726 | ||
|
|
bf5609a39b | ||
|
|
4ed5011a8f | ||
|
|
68d911431f | ||
|
|
d0716b4995 | ||
|
|
84a519e84d | ||
|
|
e1a6904eca | ||
|
|
bc200c663f | ||
|
|
009f3a8fd9 | ||
|
|
cfe695c35d | ||
|
|
5eb77cbb18 | ||
|
|
5ab8486a6c | ||
|
|
8691afd850 | ||
|
|
3517ec42c9 | ||
|
|
9dada2fd30 | ||
|
|
84a7d8b30d | ||
|
|
a50d80b5fc | ||
|
|
5e1a228328 | ||
|
|
b4f4b857f3 | ||
|
|
658c94a2d1 | ||
|
|
05375fed7a | ||
|
|
5961bf000d | ||
|
|
87c67e1b1f | ||
|
|
c79abb3e5f | ||
|
|
8ce5a35e3b | ||
|
|
dd0ba8b09d | ||
|
|
f18b8f8fb1 | ||
|
|
c76adb8c01 | ||
|
|
1b95f2fe39 | ||
|
|
1400936760 | ||
|
|
04289091bc | ||
|
|
6acbbb4446 | ||
|
|
bc9a5cd4c2 | ||
|
|
85a2c1502a | ||
|
|
15de95ff62 | ||
|
|
019613004d | ||
|
|
c204339dbb | ||
|
|
981ccd2a74 | ||
|
|
20b2eb22d4 | ||
|
|
6c0ef5ba33 | ||
|
|
2dbc4ea601 | ||
|
|
54b6d8f85c | ||
|
|
5e84633e76 | ||
|
|
7fbff71689 | ||
|
|
164e7c150d | ||
|
|
d87e3087f3 | ||
|
|
56ca3afbad | ||
|
|
b7fa68dff9 | ||
|
|
5214739618 | ||
|
|
2bb2036380 | ||
|
|
de693c9c68 | ||
|
|
bb5ea68f03 | ||
|
|
b4131dbeae | ||
|
|
e3e1d734b6 | ||
|
|
25671ba94f | ||
|
|
290ffdd80c | ||
|
|
753591105a | ||
|
|
5d0bbe74e0 | ||
|
|
ff1751350f | ||
|
|
fdd3cb3074 | ||
|
|
4f81fb417f | ||
|
|
39886cf57c | ||
|
|
9a1f3a6bb5 | ||
|
|
ac9b7de295 | ||
|
|
d4406cf02b | ||
|
|
577461bff4 | ||
|
|
289466bdb8 | ||
|
|
3c2e227d32 | ||
|
|
7dfdf54e97 | ||
|
|
f61a390bd9 | ||
|
|
b827d75c3e | ||
|
|
784e3de8a1 | ||
|
|
876d0eb5da | ||
|
|
6e9760d5d9 | ||
|
|
aafd2a20d9 | ||
|
|
284e66f0ba | ||
|
|
8995cddfa5 | ||
|
|
8401ffff22 | ||
|
|
a6d0db174b | ||
|
|
c7d7810e68 | ||
|
|
d26e317d25 | ||
|
|
a051fd49a9 | ||
|
|
f836e92d58 | ||
|
|
1db502ef4e | ||
|
|
703e981a8b | ||
|
|
8878ece19f | ||
|
|
7c366669c7 | ||
|
|
61a1ed0e49 | ||
|
|
9b2e90279d | ||
|
|
a1d35d488d | ||
|
|
a3bd62482d | ||
|
|
d0458e2e7a |
@@ -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 -}}
|
||||
@@ -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 -}}
|
||||
@@ -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
48
.github/cliff.toml
vendored
Normal 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"
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release-build.yml
vendored
2
.github/workflows/release-build.yml
vendored
@@ -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
|
||||
|
||||
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Tests (rqlite)
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
|
||||
jobs:
|
||||
test-rqlite:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
rqlite:
|
||||
image: rqlite/rqlite:latest
|
||||
ports:
|
||||
- 4001:4001
|
||||
env:
|
||||
# the HTTP address the rqlite node should advertise
|
||||
HTTP_ADV_ADDR: "localhost:4001"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache-dependency-path: "**/*.sum"
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
env:
|
||||
# set Mailpit to use the rqlite service container
|
||||
MP_DATABASE: "http://localhost:4001"
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
1008
CHANGELOG.md
1008
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,6 @@ RUN apk upgrade --no-cache && apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 1110/tcp 8025/tcp
|
||||
|
||||
HEALTHCHECK --interval=15s CMD /mailpit readyz
|
||||
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
|
||||
12
README.md
12
README.md
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
|
||||
36
cmd/dump.go
Normal file
36
cmd/dump.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/dump"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// dumpCmd represents the dump command
|
||||
var dumpCmd = &cobra.Command{
|
||||
Use: "dump <database> <output-dir>",
|
||||
Short: "Dump all messages from a database to a directory",
|
||||
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
|
||||
|
||||
The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a
|
||||
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
|
||||
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := dump.Sync(args[0]); err != nil {
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(dumpCmd)
|
||||
|
||||
dumpCmd.Flags().SortFlags = false
|
||||
|
||||
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
|
||||
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
|
||||
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
|
||||
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
}
|
||||
53
cmd/root.go
53
cmd/root.go
@@ -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")
|
||||
@@ -289,6 +331,7 @@ func initConfigFromEnv() {
|
||||
config.SMTPRelayConfig.OverrideFrom = os.Getenv("MP_SMTP_RELAY_OVERRIDE_FROM")
|
||||
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv("MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
@@ -298,6 +341,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 +372,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 +393,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")
|
||||
|
||||
@@ -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
|
||||
@@ -212,6 +237,7 @@ type SMTPRelayConfigStruct struct {
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
|
||||
PreserveMessageIDs bool `yaml:"preserve-message-ids"` // preserve the original Message-ID when relaying
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
@@ -223,7 +249,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 +277,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 +300,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 +335,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 +464,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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
57
go.mod
57
go.mod
@@ -1,63 +1,72 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.23
|
||||
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.1
|
||||
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/kovidgoyal/imaging v1.6.3
|
||||
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/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
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.34.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.34.5
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.25.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.38.0
|
||||
)
|
||||
|
||||
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-20250207012021-f9890c6ad9f3 // 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.12 // 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
|
||||
)
|
||||
|
||||
170
go.sum
170
go.sum
@@ -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.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/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/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
|
||||
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
|
||||
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/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/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,17 +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/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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=
|
||||
@@ -102,59 +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.25.0 h1:hGHKfroCXrCDTyGVR8o4HCON5/HWvc7C1uocS+VnaZs=
|
||||
github.com/vanng822/go-premailer v1.25.0/go.mod h1:8WJKIPZtegxqSOA8+eDFx7QNesKmMYfGEIodLTJqrtM=
|
||||
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-20250207012021-f9890c6ad9f3 h1:qNgPs5exUA+G0C96DrPwNrvLSj7GT/9D+3WMWUcUg34=
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.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 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
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.12 h1:Fsnh0A7XLXylYNwIOJmKux9PhnfrIvMaMnjuyJ1t/f4=
|
||||
modernc.org/libc v1.61.12/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.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
||||
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=
|
||||
|
||||
258
install.sh
258
install.sh
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
163
internal/dump/dump.go
Normal file
163
internal/dump/dump.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package dump is used to export all messages from mailpit into a directory
|
||||
package dump
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
)
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
outDir string
|
||||
|
||||
// Base URL of mailpit instance
|
||||
base string
|
||||
|
||||
// URL is the base URL of a remove Mailpit instance
|
||||
URL string
|
||||
|
||||
summary = []storage.MessageSummary{}
|
||||
)
|
||||
|
||||
// Sync will sync all messages from the specified database or API to the specified output directory
|
||||
func Sync(d string) error {
|
||||
|
||||
outDir = path.Clean(d)
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
return errors.New("Invalid URL")
|
||||
}
|
||||
|
||||
base = strings.TrimRight(URL, "/") + "/"
|
||||
}
|
||||
|
||||
if base == "" && config.Database == "" {
|
||||
return errors.New("No database or API URL specified")
|
||||
}
|
||||
|
||||
if !tools.IsDir(outDir) {
|
||||
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := loadIDs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := saveMessages(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadIDs will load all message IDs from the specified database or API
|
||||
func loadIDs() error {
|
||||
if base != "" {
|
||||
// remote
|
||||
logger.Log().Debugf("Fetching messages summary from %s", base)
|
||||
res, err := http.Get(base + "api/v1/messages?limit=0")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary = data.Messages
|
||||
|
||||
} else {
|
||||
// make sure the database isn't pruned while open
|
||||
config.MaxMessages = 0
|
||||
|
||||
var err error
|
||||
// local database
|
||||
if err = storage.InitDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
|
||||
|
||||
summary, err = storage.List(0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
return errors.New("No messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveMessages() error {
|
||||
for _, m := range summary {
|
||||
out := path.Join(outDir, m.ID+".eml")
|
||||
|
||||
// skip if message exists
|
||||
if tools.IsFile(out) {
|
||||
continue
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
if base != "" {
|
||||
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())
|
||||
continue
|
||||
}
|
||||
|
||||
b, err = io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
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())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
|
||||
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.Chtimes(out, m.Created, m.Created)
|
||||
|
||||
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2024-11-29 15:25:23 +0000",
|
||||
"last_update_date":"2025-06-12 18:27:28 +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 {}}` doesn’t work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."}
|
||||
},
|
||||
@@ -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",
|
||||
@@ -1838,9 +1822,9 @@
|
||||
"last_test_date":"2019-10-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"n"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n","16":"a #2"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"n"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors.","2":"Partial. Supported with mouse clicks. Not supported with keyboard input."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1902,7 +1886,7 @@
|
||||
"last_test_date":"2019-10-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"n"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n","16":"y"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"n"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
},
|
||||
@@ -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))"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -2638,7 +2622,7 @@
|
||||
"last_test_date":"2023-12-06",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-decoration-line.html",
|
||||
"test_results_url":"https://testi.at/proj/kg6y1a4eiynkf8z7t4",
|
||||
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -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,12 +3881,12 @@
|
||||
"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",
|
||||
"test_results_url":"https://testi.at/proj/e6ndurbxtpz9hz95hp",
|
||||
"stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2023-09":"y"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"thunderbird":{"macos":{"102.10.1":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"y"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"u"}},"gmx":{"desktop-webmail":{"2023-09":"u"}},"web-de":{"desktop-webmail":{"2023-09":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2023-09":"y"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"thunderbird":{"macos":{"102.10.1":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"y"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"u"}},"gmx":{"desktop-webmail":{"2023-09":"u"}},"web-de":{"desktop-webmail":{"2023-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -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).)"}
|
||||
},
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
81
internal/htmlcheck/inline_test.go
Normal file
81
internal/htmlcheck/inline_test.go
Normal 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)
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -100,6 +100,9 @@ func TestPOP3(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// allow for background delete when using rqlite driver
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
|
||||
@@ -80,7 +80,7 @@ func Run() {
|
||||
|
||||
type message struct {
|
||||
ID string
|
||||
Size float64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
@@ -211,22 +211,33 @@ 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
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
if len(args) > 0 {
|
||||
arg, _ := getSafeArg(args, 0)
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil || nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", nr, int64(messages[nr-1].Size)))
|
||||
} else {
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
case "UIDL":
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
|
||||
190
internal/prometheus/metrics.go
Normal file
190
internal/prometheus/metrics.go
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ var (
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
@@ -37,6 +36,7 @@ type Triggers struct {
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
// swagger:model Trigger
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -362,6 +362,11 @@ func (s *session) serve() {
|
||||
var to []string
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
// Send banner.
|
||||
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName)
|
||||
|
||||
@@ -474,12 +479,7 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
if len(to) == s.srv.MaxRecipients {
|
||||
if len(to) >= s.srv.MaxRecipients {
|
||||
s.writef("452 4.5.3 Too many recipients")
|
||||
} else {
|
||||
accept := true
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,23 @@ func TestCmdRCPT(t *testing.T) {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMaxRecipients(t *testing.T) {
|
||||
conn := newConn(t, &Server{MaxRecipients: 3})
|
||||
|
||||
cmdCode(t, conn, "EHLO host.example.com", "250")
|
||||
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
|
||||
|
||||
cmdCode(t, conn, "RCPT TO: <recipient1@example.com>", "250")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient2@example.com>", "250")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient3@example.com>", "250")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient4@example.com>", "452")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient5@example.com>", "452")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdDATA(t *testing.T) {
|
||||
conn := newConn(t, &Server{})
|
||||
cmdCode(t, conn, "EHLO host.example.com", "250")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 float64 // use float64 for rqlite compatibility
|
||||
|
||||
// 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 + uint64(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 + uint64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
|
||||
@@ -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,65 +210,51 @@ func StatsGet() MailboxStats {
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() float64 {
|
||||
var total float64
|
||||
func CountTotal() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() float64 {
|
||||
var total float64
|
||||
func CountUnread() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() float64 {
|
||||
var total float64
|
||||
func CountRead() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// DbSize returns the size of the SQLite database.
|
||||
func DbSize() float64 {
|
||||
var total sql.NullFloat64
|
||||
func DbSize() uint64 {
|
||||
var total sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
|
||||
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 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.Float64)
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -175,21 +190,23 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
|
||||
q := sqlf.From(tenant("mailbox") + " m").
|
||||
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
|
||||
OrderBy("m.Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
OrderBy("m.Created DESC")
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(start)
|
||||
}
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -209,7 +226,7 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -277,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 float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -304,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,
|
||||
}
|
||||
|
||||
@@ -345,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
|
||||
}
|
||||
|
||||
@@ -356,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 {
|
||||
@@ -372,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)
|
||||
@@ -381,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
|
||||
@@ -440,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
|
||||
}
|
||||
@@ -473,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
|
||||
@@ -550,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
|
||||
@@ -599,16 +617,18 @@ 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 float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := rows.Scan(&id, &size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toDelete = append(toDelete, id)
|
||||
totalSize = totalSize + size
|
||||
totalSize = totalSize + uint64(size)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -645,7 +665,7 @@ func DeleteMessages(ids []string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
addDeletedSize(totalSize)
|
||||
|
||||
logMessagesDeleted(len(toDelete))
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
5
internal/storage/schemas/1.23.0.sql
Normal file
5
internal/storage/schemas/1.23.0.sql
Normal 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;
|
||||
@@ -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 float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
@@ -65,7 +65,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -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 float64 // use float64 for rqlite compatibility
|
||||
|
||||
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", int64(unread), search, elapsed)
|
||||
|
||||
return int64(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 float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -128,7 +161,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
deleteSize = deleteSize + size
|
||||
deleteSize = deleteSize + uint64(size)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -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 float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.NullFloat64 // use float64 for rqlite compatibility
|
||||
err := sqlf.From(tenant("settings")).
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", "DeletedSize").
|
||||
@@ -47,11 +47,11 @@ func getDeletedSize() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() float64 {
|
||||
func totalMessagesSize() uint64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("mailbox")).
|
||||
Select("SUM(Size)").To(&result).
|
||||
@@ -61,11 +61,11 @@ func totalMessagesSize() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func GetAllTags() []string {
|
||||
func GetAllTagsCount() map[string]int64 {
|
||||
var tags = make(map[string]int64)
|
||||
var name string
|
||||
var total int64
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
@@ -181,7 +181,7 @@ func GetAllTagsCount() map[string]int64 {
|
||||
GroupBy(tenant("message_tags.TagID")).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
tags[name] = int64(total)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
23
internal/tools/fs.go
Normal file
23
internal/tools/fs.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func IsDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -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
26
main.go
@@ -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))
|
||||
}
|
||||
|
||||
1530
package-lock.json
generated
1530
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -57,7 +57,7 @@ func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > -1 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ type webUIConfiguration struct {
|
||||
BlockedRecipients string
|
||||
// Overrides the "From" address for all relayed messages
|
||||
OverrideFrom string
|
||||
// Preserve the original Message-IDs when relaying messages
|
||||
PreserveMessageIDs bool
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
// swagger:ignore
|
||||
RecipientAllowlist string
|
||||
@@ -75,6 +78,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
|
||||
@@ -114,6 +120,8 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
conf.MessageRelay.PreserveMessageIDs = config.SMTPRelayConfig.PreserveMessageIDs
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
@@ -121,6 +129,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 {
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
// ChaosTriggers is the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
// ChaosTriggers are the Chaos triggers
|
||||
type ChaosTriggers chaos.Triggers
|
||||
|
||||
// Response for the Chaos triggers configuration
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,19 +170,20 @@ 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
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
// update Message-Id with unique ID
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
if !config.SMTPRelayConfig.PreserveMessageIDs {
|
||||
// replace the Message-ID header with unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := smtpd.Relay(from, data.To, msg); err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ func embedController(w http.ResponseWriter, r *http.Request) {
|
||||
p = p + "index.html"
|
||||
}
|
||||
|
||||
p = strings.TrimLeft(p, config.Webroot) // server webroot config
|
||||
p = path.Join("ui", p) // add go:embed path to path prefix
|
||||
p = strings.TrimPrefix(p, config.Webroot) // server webroot config
|
||||
p = path.Join("ui", p) // add go:embed path to path prefix
|
||||
|
||||
b, err := distFS.ReadFile(p)
|
||||
if err != nil {
|
||||
@@ -71,6 +71,8 @@ func contentType(p string) string {
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(p, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(p, ".woff"):
|
||||
return "font/woff"
|
||||
case strings.HasSuffix(p, ".woff2"):
|
||||
return "font/woff2"
|
||||
default:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}})
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
server/ui-src/components/AppBadge.vue
Normal file
59
server/ui-src/components/AppBadge.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> & <code>Mark all read</code> confirmation
|
||||
dialogs
|
||||
Skip
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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>
|
||||
<{{ u }}>
|
||||
@@ -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' : ''">
|
||||
|
||||
@@ -140,8 +140,8 @@ export default {
|
||||
will be rejected:
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>
|
||||
</li>
|
||||
<li class="form-text">
|
||||
For testing purposes, a new unique <code>Message-Id</code> will be generated on send.
|
||||
<li v-if="!mailbox.uiConfig.MessageRelay.PreserveMessageIDs" class="form-text">
|
||||
For testing purposes, a new unique <code>Message-ID</code> will be generated on send.
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||
The <code>From</code> email address has been overridden by the relay configuration to
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Triggers"
|
||||
"$ref": "#/definitions/ChaosTriggers"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -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,12 +1197,16 @@
|
||||
},
|
||||
"Size": {
|
||||
"description": "Size in bytes",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/storage"
|
||||
},
|
||||
"ChaosTriggers": {
|
||||
"description": "ChaosTriggers are the Chaos triggers",
|
||||
"$ref": "#/definitions/Triggers"
|
||||
},
|
||||
"HTMLCheckResponse": {
|
||||
"description": "Response represents the HTML check response struct",
|
||||
"type": "object",
|
||||
@@ -1417,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",
|
||||
@@ -1443,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"
|
||||
},
|
||||
@@ -1465,6 +1510,9 @@
|
||||
"$ref": "#/definitions/Attachment"
|
||||
}
|
||||
},
|
||||
"ListUnsubscribe": {
|
||||
"$ref": "#/definitions/ListUnsubscribe"
|
||||
},
|
||||
"MessageID": {
|
||||
"description": "Message ID",
|
||||
"type": "string"
|
||||
@@ -1482,8 +1530,8 @@
|
||||
},
|
||||
"Size": {
|
||||
"description": "Message size in bytes",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"Subject": {
|
||||
"description": "Message subject",
|
||||
@@ -1546,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"
|
||||
},
|
||||
@@ -1574,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",
|
||||
@@ -1616,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",
|
||||
@@ -1636,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"
|
||||
}
|
||||
},
|
||||
@@ -1920,7 +1974,7 @@
|
||||
"$ref": "#/definitions/Trigger"
|
||||
}
|
||||
},
|
||||
"$ref": "#/definitions/Triggers"
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
},
|
||||
"WebUIConfiguration": {
|
||||
"description": "Response includes global web UI settings",
|
||||
@@ -1934,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"
|
||||
@@ -1958,6 +2016,10 @@
|
||||
"description": "Overrides the \"From\" address for all relayed messages",
|
||||
"type": "string"
|
||||
},
|
||||
"PreserveMessageIDs": {
|
||||
"description": "Preserve the original Message-IDs when relaying messages",
|
||||
"type": "boolean"
|
||||
},
|
||||
"ReturnPath": {
|
||||
"description": "Enforced Return-Path (if set) for relay bounces",
|
||||
"type": "string"
|
||||
@@ -2002,7 +2064,7 @@
|
||||
"ChaosResponse": {
|
||||
"description": "Response for the Chaos triggers configuration",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Triggers"
|
||||
"$ref": "#/definitions/ChaosTriggers"
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
|
||||
Reference in New Issue
Block a user