mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 04:27:01 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f40f95555a | ||
|
|
a5558d97ce | ||
|
|
50c072ef4f | ||
|
|
561032f367 | ||
|
|
8f1b7b6ec0 | ||
|
|
be94385f38 | ||
|
|
61306e1ae4 | ||
|
|
dac9fcf735 | ||
|
|
3528bc8da7 | ||
|
|
cb3300212f | ||
|
|
f377414c3b | ||
|
|
a2db203a08 | ||
|
|
b1eb58c9c8 | ||
|
|
76b7e74049 | ||
|
|
ed0caa0081 | ||
|
|
45e67b5cac | ||
|
|
0c63c29769 | ||
|
|
f4d6dd5c39 | ||
|
|
496bf17db7 | ||
|
|
86b5524217 | ||
|
|
cba9f0043c | ||
|
|
a1b08ea2bc | ||
|
|
3d6d899a6d | ||
|
|
9687329fc1 | ||
|
|
04410ff463 | ||
|
|
a29b969e61 | ||
|
|
8425780ccd | ||
|
|
8331e11f7f | ||
|
|
d7df895261 | ||
|
|
e2fab49873 | ||
|
|
a95bc3d29f | ||
|
|
f278933bb9 | ||
|
|
4d86297169 | ||
|
|
2a6ab0476b | ||
|
|
b2ffb7476d | ||
|
|
338f205234 | ||
|
|
168049faf9 | ||
|
|
2a1a5ae852 | ||
|
|
e30754a167 | ||
|
|
fd46d4076b | ||
|
|
7703d09919 | ||
|
|
b3e7995342 | ||
|
|
c8937e218f | ||
|
|
82cfd605e5 | ||
|
|
d67feec713 | ||
|
|
9f4908d11d | ||
|
|
13027bf10b |
@@ -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/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9.0.0
|
||||
- uses: actions/stale@v9.1.0
|
||||
with:
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
985
CHANGELOG.md
985
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"]
|
||||
|
||||
14
README.md
14
README.md
@@ -46,8 +46,10 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
|
||||
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
|
||||
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
|
||||
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
|
||||
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
|
||||
@@ -66,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")
|
||||
}
|
||||
56
cmd/root.go
56
cmd/root.go
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server"
|
||||
@@ -82,12 +83,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")
|
||||
@@ -102,6 +105,8 @@ 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")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
@@ -122,6 +127,13 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
|
||||
|
||||
// SMTP forwarding
|
||||
rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
|
||||
|
||||
// Chaos
|
||||
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
|
||||
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
|
||||
|
||||
// POP3 server
|
||||
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
|
||||
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
|
||||
@@ -173,6 +185,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")
|
||||
@@ -208,7 +226,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
@@ -224,6 +242,12 @@ 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
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
@@ -231,7 +255,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
@@ -272,22 +296,46 @@ 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")
|
||||
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
|
||||
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
|
||||
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
|
||||
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")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
|
||||
config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
|
||||
if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
|
||||
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")
|
||||
config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
|
||||
config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
|
||||
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
|
||||
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
|
||||
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
|
||||
|
||||
// Chaos
|
||||
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
|
||||
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
|
||||
|
||||
// POP3 server
|
||||
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
|
||||
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")
|
||||
}
|
||||
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
|
||||
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
|
||||
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
|
||||
|
||||
270
config/config.go
270
config/config.go
@@ -5,19 +5,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,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
|
||||
@@ -63,6 +69,9 @@ 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
|
||||
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
@@ -114,22 +123,12 @@ var (
|
||||
// including x-tags & plus-addresses
|
||||
TagsDisable string
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
|
||||
SMTPRelayConfig SMTPRelayConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
@@ -143,6 +142,22 @@ var (
|
||||
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
|
||||
SMTPRelayMatchingRegexp *regexp.Regexp
|
||||
|
||||
// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfigFile string
|
||||
|
||||
// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfig SMTPForwardConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
|
||||
POP3Listen = "[::]:1110"
|
||||
|
||||
@@ -158,6 +173,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
|
||||
|
||||
@@ -176,6 +194,9 @@ var (
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
|
||||
// ChaosTriggers are parsed and set in the chaos module
|
||||
ChaosTriggers string
|
||||
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
@@ -191,15 +212,17 @@ 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
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
@@ -209,6 +232,22 @@ type SMTPRelayConfigStruct struct {
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
}
|
||||
|
||||
// SMTPForwardConfigStruct struct for parsing yaml & storing variables
|
||||
type SMTPForwardConfigStruct struct {
|
||||
To string `yaml:"to"` // comma-separated list of email addresses
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
cssFontRestriction := "*"
|
||||
@@ -227,6 +266,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 {
|
||||
@@ -281,7 +324,7 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
@@ -344,6 +387,14 @@ func VerifyConfig() error {
|
||||
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
if err := parseChaosTriggers(); err != nil {
|
||||
return fmt.Errorf("[chaos] %s", err.Error())
|
||||
}
|
||||
|
||||
if chaos.Enabled {
|
||||
logger.Log().Info("[chaos] is enabled")
|
||||
}
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
@@ -358,7 +409,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)
|
||||
@@ -465,6 +516,15 @@ func VerifyConfig() error {
|
||||
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// separate forwarding config validation to account for environment variables
|
||||
if err := validateForwardConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if DemoMode {
|
||||
MaxMessages = 1000
|
||||
// this deserves a warning
|
||||
@@ -473,171 +533,3 @@ func VerifyConfig() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[smtp] relay configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("[smtp] relay host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
|
||||
if SMTPRelayConfig.AllowedRecipients == "" {
|
||||
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPRelayConfig (if Host is set)
|
||||
func validateRelayConfig() error {
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
51
config/utils.go
Normal file
51
config/utils.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
290
config/validators.go
Normal file
290
config/validators.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[relay] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("[relay] host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
|
||||
if SMTPRelayConfig.AllowedRecipients == "" {
|
||||
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPRelayConfig (if Host is set)
|
||||
func validateRelayConfig() error {
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[relay] host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[relay] host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("[relay] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPForwardConfigFile (if set)
|
||||
func parseForwardConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[forward] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return errors.New("[forward] host not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPForwardConfig (if Host is set)
|
||||
func validateForwardConfig() error {
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Port == 0 {
|
||||
SMTPForwardConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
|
||||
|
||||
if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
|
||||
SMTPForwardConfig.Auth = "none"
|
||||
} else if SMTPForwardConfig.Auth == "plain" {
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPForwardConfig.Auth == "login" {
|
||||
SMTPForwardConfig.Auth = "login"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
|
||||
SMTPForwardConfig.Auth = "cram-md5"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
|
||||
return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.To == "" {
|
||||
return errors.New("[forward] To addresses missing")
|
||||
}
|
||||
|
||||
to := []string{}
|
||||
addresses := strings.Split(SMTPForwardConfig.To, ",")
|
||||
for _, a := range addresses {
|
||||
a = strings.TrimSpace(a)
|
||||
m, err := mail.ParseAddress(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
|
||||
}
|
||||
to = append(to, m.Address)
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return errors.New("[forward] no valid To addresses found")
|
||||
}
|
||||
|
||||
// overwrite the To field with the cleaned up list
|
||||
SMTPForwardConfig.To = strings.Join(to, ",")
|
||||
|
||||
if SMTPForwardConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func parseChaosTriggers() error {
|
||||
if ChaosTriggers == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
|
||||
|
||||
parts := strings.Split(ChaosTriggers, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if !re.MatchString(p) {
|
||||
return fmt.Errorf("invalid argument: %s", p)
|
||||
}
|
||||
|
||||
matches := re.FindAllStringSubmatch(p, 1)
|
||||
key := matches[0][1]
|
||||
errorCode, err := strconv.Atoi(matches[0][2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probability, err := strconv.Atoi(matches[0][3])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := chaos.Set(key, errorCode, probability); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
59
go.mod
59
go.mod
@@ -1,51 +1,52 @@
|
||||
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.0
|
||||
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-20241205020045-f7e15b2f3e62
|
||||
github.com/goccy/go-yaml v1.17.1
|
||||
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-20241013203532-4385768ae85d
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.3
|
||||
github.com/vanng822/go-premailer v1.22.0
|
||||
golang.org/x/net v0.32.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/time v0.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.2
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.24.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.11.0
|
||||
modernc.org/sqlite v1.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // 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/hashicorp/golang-lru/v2 v2.0.7 // 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/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/olekukonko/tablewriter v1.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -53,15 +54,11 @@ require (
|
||||
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.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
|
||||
modernc.org/libc v1.61.4 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
181
go.sum
181
go.sum
@@ -1,33 +1,32 @@
|
||||
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.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
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/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/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.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||
github.com/goccy/go-yaml v1.17.1/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-20241205020045-f7e15b2f3e62 h1:pbAFUZisjG4s6sxvRJvf2N7vhpCvx2Oxb3PmS6pDO1g=
|
||||
github.com/gomarkdown/markdown v0.0.0-20241205020045-f7e15b2f3e62/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/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=
|
||||
@@ -36,25 +35,16 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/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=
|
||||
@@ -85,114 +75,99 @@ 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-20241013203532-4385768ae85d h1:c88ius/WcN19inn14R+X2EQCFjjAu92txgdxNNnGxDI=
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
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=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.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/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.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.22.0 h1:5gG92q3nG3BwcfUUDzrSDbYDbpwYC/lri4nba+vhdJQ=
|
||||
github.com/vanng822/go-premailer v1.22.0/go.mod h1:K7DxRBW6AxdZUTqmW9jU6041CtfAWiP9uSXm2WmMB1k=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/vanng822/go-premailer v1.24.0 h1:b4MpHLVdlA7QOwk5OJIEvWnIpCCdEhEDQpJ/AkEYcpo=
|
||||
github.com/vanng822/go-premailer v1.24.0/go.mod h1:gjLku4P5inmyu+MM7544lOjhaW8F3TdIqboFVcZGwZE=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.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.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
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=
|
||||
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.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.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.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.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.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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=
|
||||
@@ -201,51 +176,45 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
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.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
|
||||
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
|
||||
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
|
||||
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.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
|
||||
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
|
||||
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
|
||||
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
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.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/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.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
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
|
||||
|
||||
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-05-17 12:48:23 +0000",
|
||||
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
|
||||
"data":[
|
||||
{
|
||||
@@ -190,9 +190,9 @@
|
||||
"last_test_date":"2019-08-20",
|
||||
"test_url":"https://www.caniemail.com/tests/css-media.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"n","12":"y","13":"y","15":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"n","12":"y","13":"y","15":"y","18.3.2":"a #2"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y","2025-04":"n"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. The first rule inside a media query is not prefixed."}
|
||||
"notes_by_num":{"1":"Buggy. The first rule inside a media query is not prefixed.","2":"Partial. `orientation:portrait` is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -521,7 +521,7 @@
|
||||
"description":"Support for border radius logical properties",
|
||||
"url":"https://www.caniemail.com/features/css-border-radius-logical/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius",
|
||||
"last_test_date":"2022-08-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
|
||||
@@ -910,7 +910,7 @@
|
||||
"last_test_date":"2022-08-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-font-kerning.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list",
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"128.9.0":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"last_test_date":"2019-09-27",
|
||||
"test_url":"https://www.caniemail.com/tests/css-width-height.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"y #1"},"ios":{"2019-09":"y #1"},"android":{"2019-09":"y #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
|
||||
},
|
||||
@@ -1177,8 +1177,8 @@
|
||||
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
|
||||
"url":"https://www.caniemail.com/features/css-inset/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"tags":["i18n"],
|
||||
"keywords":"inset-block, inset-inline, inset-inline-start, inset-inline-end, inset-block-start, inset-block-end",
|
||||
"last_test_date":"2024-05-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-inset.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
|
||||
@@ -1230,9 +1230,9 @@
|
||||
"last_test_date":"2021-05-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-positioning.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list",
|
||||
"stats":{"apple-mail":{"macos":{"14":"y"},"ios":{"14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"14":"y"},"ios":{"14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y","6.2.01.1":"a #2"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `left` and `top` are not supported."}
|
||||
"notes_by_num":{"1":"Partial. `left` and `top` are not supported.","2":"Partial. Percentages values are not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1278,9 +1278,9 @@
|
||||
"last_test_date":"2021-12-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #3"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts."}
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts.","3":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1374,7 +1374,7 @@
|
||||
"last_test_date":"2022-07-12",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
|
||||
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1395,22 +1395,6 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-margin-inline",
|
||||
"title":"margin-inline & margin-block",
|
||||
"description":"Support for the `margin-inline` and `margin-block` shorthand properties.",
|
||||
"url":"https://www.caniemail.com/features/css-margin-inline/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"margin-inline, margin-block",
|
||||
"last_test_date":"2022-07-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
|
||||
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-margin",
|
||||
"title":"margin",
|
||||
@@ -1449,7 +1433,7 @@
|
||||
"description":"",
|
||||
"url":"https://www.caniemail.com/features/css-max-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"max, block, size",
|
||||
"last_test_date":"2022-09-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-block-size.html",
|
||||
@@ -1481,7 +1465,7 @@
|
||||
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-max-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"max, inline, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
|
||||
@@ -1513,7 +1497,7 @@
|
||||
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-min-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"min, block, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
|
||||
@@ -1545,7 +1529,7 @@
|
||||
"description":"",
|
||||
"url":"https://www.caniemail.com/features/css-min-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"min, inline, size",
|
||||
"last_test_date":"2022-08-30",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
|
||||
@@ -1614,7 +1598,7 @@
|
||||
"last_test_date":"2023-08-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-nesting.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list",
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n","137.0b3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `E { F {}}` 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",
|
||||
@@ -2254,9 +2238,9 @@
|
||||
"last_test_date":"2021-12-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #2"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill)."}
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -2777,7 +2761,7 @@
|
||||
"description":"Sets the orientation of the text characters in vertical mode.",
|
||||
"url":"https://www.caniemail.com/features/css-text-orientation/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"vertical orientation",
|
||||
"last_test_date":"2023-12-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-orientation.html",
|
||||
@@ -3385,7 +3369,7 @@
|
||||
"description":"Represents an abbreviation or acronym.",
|
||||
"url":"https://www.caniemail.com/features/html-abbr/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-09-13",
|
||||
"test_url":"https://www.caniemail.com/tests/html-abbr.html",
|
||||
@@ -3566,7 +3550,7 @@
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-background.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Only supported on the `<body>` element."}
|
||||
},
|
||||
@@ -3630,7 +3614,7 @@
|
||||
"last_test_date":"2021-11-30",
|
||||
"test_url":"https://www.caniemail.com/tests/html-body.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/M1w9fKYqtXsrlJ2mlElp9b2RoSd7lDcWwftkDazPgy4hm/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n","2025-04":"a #1"},"android":{"2021-11":"n","2025-04":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Replaced by a `<div>` with supported attributes."}
|
||||
},
|
||||
@@ -3705,7 +3689,7 @@
|
||||
"description":"A short fragment of computer code.",
|
||||
"url":"https://www.caniemail.com/features/html-code/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-04-25",
|
||||
"test_url":"https://www.caniemail.com/tests/html-code.html",
|
||||
@@ -3785,7 +3769,7 @@
|
||||
"description":"Indicates the directionality of the element's text.",
|
||||
"url":"https://www.caniemail.com/features/html-dir/",
|
||||
"category":"html",
|
||||
"tags":["i18n"],
|
||||
"tags":["i18n","accessibility"],
|
||||
"keywords":"direction, ltr, rtl",
|
||||
"last_test_date":"2021-11-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-direction.html",
|
||||
@@ -3897,7 +3881,7 @@
|
||||
"description":"HTML horizontal rule",
|
||||
"url":"https://www.caniemail.com/features/html-hr/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-09-08",
|
||||
"test_url":"https://www.caniemail.com/tests/html-hr.html",
|
||||
@@ -3929,7 +3913,7 @@
|
||||
"description":"Displays an image into the document",
|
||||
"url":"https://www.caniemail.com/features/html-img/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["performance","accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-12-16",
|
||||
"test_url":"https://www.caniemail.com/tests/html-img.html",
|
||||
@@ -4461,8 +4445,8 @@
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-07-27",
|
||||
"test_url":"https://www.caniemail.com/tests/html-style.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/od5IYQtx8yIbIUbeRyQXnP0yzFKEm2E9CKa3FU4BcEXFv/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/CAMb612bxbVwRWPhM4wZKNhhdcdkNxj0Rj6dtRRw6LQUO/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y","2025-04":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{"1":"Partial. Not supported inside the `<body>`.","2":"Partial. Not supported with non Google accounts.","3":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","4":"Buggy. `<style>` elements need to be declared before their rules are used.","5":"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)"}
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
121
internal/smtpd/chaos/chaos.go
Normal file
121
internal/smtpd/chaos/chaos.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
|
||||
// See https://en.wikipedia.org/wiki/Chaos_engineering
|
||||
// See https://mailpit.axllent.org/docs/integration/chaos/
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
// Enabled is a flag to enable or disable support for chaos
|
||||
Enabled = false
|
||||
|
||||
// Config is the global Chaos configuration
|
||||
Config = Triggers{
|
||||
Sender: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Recipient: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Authentication: Trigger{ErrorCode: 535, Probability: 0},
|
||||
}
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
// swagger:model Triggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
Sender Trigger
|
||||
// Recipient trigger to fail on To, Cc, Bcc
|
||||
Recipient Trigger
|
||||
// Authentication trigger to fail while authenticating (auth must be configured)
|
||||
Authentication Trigger
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
// swagger:model Trigger
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
// example: 451
|
||||
ErrorCode int
|
||||
|
||||
// Probability (chance) of triggering the error. The value must range from 0 to 100.
|
||||
// required: true
|
||||
// example: 5
|
||||
Probability int
|
||||
}
|
||||
|
||||
// SetFromStruct will set a whole map of chaos configurations (ie: API)
|
||||
func SetFromStruct(c Triggers) error {
|
||||
if c.Sender.ErrorCode == 0 {
|
||||
c.Sender.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Recipient.ErrorCode == 0 {
|
||||
c.Recipient.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Authentication.ErrorCode == 0 {
|
||||
c.Authentication.ErrorCode = 535 // default
|
||||
}
|
||||
|
||||
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set will set the chaos configuration for the given key (CLI & setMap())
|
||||
func Set(key string, errorCode int, probability int) error {
|
||||
Enabled = true
|
||||
if errorCode < 400 || errorCode > 599 {
|
||||
return fmt.Errorf("error code must be between 400 and 599")
|
||||
}
|
||||
|
||||
if probability > 100 || probability < 0 {
|
||||
return fmt.Errorf("probability must be between 0 and 100")
|
||||
}
|
||||
|
||||
key = strings.ToLower(key)
|
||||
|
||||
switch key {
|
||||
case "sender":
|
||||
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
|
||||
case "recipient", "recipients":
|
||||
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
|
||||
case "auth", "authentication":
|
||||
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
|
||||
default:
|
||||
return fmt.Errorf("unknown key %s", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trigger will return whether the Chaos rule is triggered based on the configuration
|
||||
// and a randomly-generated percentage value.
|
||||
func (c Trigger) Trigger() (bool, int) {
|
||||
if !Enabled || c.Probability == 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
|
||||
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
|
||||
// so value must be less than (not <=) to the probability to trigger
|
||||
return int(nBig.Int64()) < c.Probability, c.ErrorCode
|
||||
}
|
||||
139
internal/smtpd/forward.go
Normal file
139
internal/smtpd/forward.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Wrapper to forward messages if configured
|
||||
func autoForwardMessage(from string, data *[]byte) {
|
||||
if config.SMTPForwardConfig.Host == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := forward(from, *data); err != nil {
|
||||
logger.Log().Errorf("[forward] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[forward] message from %s to %s via %s:%d",
|
||||
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
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 := createForwardingSMTPClient(config.SMTPForwardConfig, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
auth := forwardAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPForwardConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
to := strings.Split(config.SMTPForwardConfig.To, ",")
|
||||
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP forwarding authentication based on config
|
||||
func forwardAuthFromConfig() smtp.Auth {
|
||||
var a smtp.Auth
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "plain" {
|
||||
a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "login" {
|
||||
a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "cram-md5" {
|
||||
a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
@@ -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)
|
||||
@@ -87,6 +75,9 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
|
||||
autoRelayMessage(from, to, &data)
|
||||
|
||||
// if enabled, this will forward a copy to preconfigured addresses
|
||||
autoForwardMessage(from, &data)
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
|
||||
@@ -105,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, ", "))
|
||||
@@ -279,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 {
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Wrapper to auto relay messages if configured
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
|
||||
filteredTo := []string{}
|
||||
for _, address := range to {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
|
||||
logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address)
|
||||
logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -31,9 +33,9 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
|
||||
if config.SMTPRelayAll {
|
||||
if err := Relay(from, to, *data); err != nil {
|
||||
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
|
||||
logger.Log().Errorf("[relay] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
|
||||
logger.Log().Debugf("[relay] sent message to %s from %s via %s:%d",
|
||||
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
} else if config.SMTPRelayMatchingRegexp != nil {
|
||||
@@ -49,35 +51,63 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
}
|
||||
|
||||
if err := Relay(from, filtered, *data); err != nil {
|
||||
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
|
||||
logger.Log().Errorf("[relay] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
|
||||
logger.Log().Debugf("[relay] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -86,6 +116,15 @@ func Relay(from string, to []string, msg []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPRelayConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package smtpd implements a basic SMTP server.
|
||||
//
|
||||
// This is a modified version of https://github.com/mhale/smtpd to
|
||||
// add optional support for unix sockets.
|
||||
// add support for unix sockets and Mailpit Chaos.
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
@@ -22,13 +22,15 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
var (
|
||||
// Debug `true` enables verbose logging.
|
||||
Debug = false
|
||||
rcptToRE = regexp.MustCompile(`[Tt][Oo]: ?<([^<>\v]+)>( |$)(.*)?`)
|
||||
mailFromRE = regexp.MustCompile(`[Ff][Rr][Oo][Mm]: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
rcptToRE = regexp.MustCompile(`(?i)TO: ?<([^<>\v]+)>( |$)(.*)?`)
|
||||
mailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
|
||||
// extract mail size from 'MAIL FROM' parameter
|
||||
mailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`)
|
||||
@@ -360,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)
|
||||
|
||||
@@ -390,7 +397,7 @@ loop:
|
||||
buffer.Reset()
|
||||
case "EHLO":
|
||||
s.remoteName = args
|
||||
s.writef(s.makeEHLOResponse())
|
||||
s.writef("%s", s.makeEHLOResponse())
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
|
||||
from = ""
|
||||
@@ -411,6 +418,12 @@ loop:
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
} else {
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Sender.Trigger(); fail {
|
||||
s.writef("%d Chaos sender error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// Validate the SIZE parameter if one was sent.
|
||||
if len(match[2]) > 0 { // A parameter is present
|
||||
sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])
|
||||
@@ -426,7 +439,7 @@ loop:
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set
|
||||
err = maxSizeExceeded(s.srv.MaxSize)
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else { // SIZE ok
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
@@ -439,6 +452,7 @@ loop:
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "RCPT":
|
||||
@@ -459,11 +473,13 @@ loop:
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
} else {
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Recipient.Trigger(); fail {
|
||||
s.writef("%d Chaos recipient error", code)
|
||||
break
|
||||
}
|
||||
if len(to) == s.srv.MaxRecipients {
|
||||
|
||||
if len(to) >= s.srv.MaxRecipients {
|
||||
s.writef("452 4.5.3 Too many recipients")
|
||||
} else {
|
||||
accept := true
|
||||
@@ -507,7 +523,7 @@ loop:
|
||||
}
|
||||
break loop
|
||||
case maxSizeExceededError:
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
continue
|
||||
default:
|
||||
s.writef("451 4.3.0 Requested action aborted: local error in processing")
|
||||
@@ -526,7 +542,7 @@ loop:
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
@@ -538,7 +554,7 @@ loop:
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
@@ -546,7 +562,7 @@ loop:
|
||||
}
|
||||
|
||||
if msgID != "" {
|
||||
s.writef("250 2.0.0 Ok: queued as " + msgID)
|
||||
s.writef("250 2.0.0 Ok: queued as %s", msgID)
|
||||
} else {
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
@@ -685,6 +701,12 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Authentication.Trigger(); fail {
|
||||
s.writef("%d Chaos authentication error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
|
||||
// when attempting to use an unsupported authentication type.
|
||||
// Many servers return 5.7.4 ("Security features not supported") instead.
|
||||
@@ -703,7 +725,7 @@ loop:
|
||||
break loop
|
||||
}
|
||||
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
@@ -726,7 +748,7 @@ func (s *session) writef(format string, args ...interface{}) {
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(s.bw, line+"\r\n")
|
||||
fmt.Fprintf(s.bw, "%s\r\n", line)
|
||||
_ = s.bw.Flush()
|
||||
|
||||
if Debug {
|
||||
@@ -861,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
|
||||
}
|
||||
|
||||
@@ -869,7 +892,7 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
if arg == "" {
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -881,7 +904,7 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -929,7 +952,7 @@ func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">"
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
|
||||
data, err := s.readLine()
|
||||
if err != nil {
|
||||
|
||||
@@ -84,8 +84,8 @@ func TestCmdHELO(t *testing.T) {
|
||||
|
||||
// Verify that HELO resets the current transaction state like RSET.
|
||||
// RFC 2821 section 4.1.4 says EHLO should cause a reset, so verify that HELO does it too.
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
|
||||
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
|
||||
cmdCode(t, conn, "mail from:<sender@example.com>", "250") // Also testing case-insensitivity
|
||||
cmdCode(t, conn, "rcpt to:<recipient@example.com>", "250")
|
||||
cmdCode(t, conn, "HELO host.example.com", "250")
|
||||
cmdCode(t, conn, "DATA", "503")
|
||||
|
||||
@@ -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")
|
||||
@@ -535,7 +552,7 @@ func TestCmdSTARTTLSRequired(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMakeHeaders(t *testing.T) {
|
||||
now := time.Now().Format("Mon, _2 Jan 2006 15:04:05 -0700 (MST)")
|
||||
now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)")
|
||||
valid := "Received: from clientName (clientHost [clientIP])\r\n" +
|
||||
" by serverName (smtpd) with SMTP\r\n" +
|
||||
" for <recipient@example.com>; " +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -233,19 +258,6 @@ func DbSize() 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
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,9 +190,11 @@ 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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, 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()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
22
internal/storage/schemas/1.21.8.sql
Normal file
22
internal/storage/schemas/1.21.8.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Rebuild message_tags to remove FOREIGN KEY REFERENCES
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }};
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }};
|
||||
|
||||
ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
TagID INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID);
|
||||
|
||||
INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old;
|
||||
|
||||
DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old;
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
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;
|
||||
@@ -100,6 +100,39 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
// SearchUnreadCount returns the number of unread messages matching a search.
|
||||
// This is run one at a time to allow connected browsers to be updated.
|
||||
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
tsStart := time.Now()
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var unread int64
|
||||
|
||||
q = q.Where("Read = 0").Select(`COUNT(*)`)
|
||||
|
||||
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var ignore sql.NullString
|
||||
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", unread, search, elapsed)
|
||||
|
||||
return unread, err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
@@ -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
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,7 +28,6 @@ type Message struct {
|
||||
// Message subject
|
||||
Subject string
|
||||
// List-Unsubscribe header information
|
||||
// swagger:ignore
|
||||
ListUnsubscribe ListUnsubscribe
|
||||
// Message date if set, else date received
|
||||
Date time.Time
|
||||
@@ -117,10 +116,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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -48,7 +49,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] removed %s header", hdr)
|
||||
logger.Log().Debugf("[relay] removed %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(""), 1)
|
||||
}
|
||||
}
|
||||
@@ -57,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 {
|
||||
@@ -89,10 +92,68 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func OverrideFromHeader(msg []byte, address string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.Header.Get("From") != "" {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta("From:"))
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
originalFrom := strings.TrimRight(string(hdr[5:]), "\r\n")
|
||||
|
||||
from, err := mail.ParseAddress(originalFrom)
|
||||
if err != nil {
|
||||
// error parsing the from address, so just replace the whole line
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+address+"\r\n"), 1)
|
||||
} else {
|
||||
originalFrom = from.Address
|
||||
// replace the from email, but keep the original name
|
||||
from.Address = address
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+from.String()+"\r\n"), 1)
|
||||
}
|
||||
|
||||
// insert the original From header as X-Original-From
|
||||
msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...)
|
||||
|
||||
logger.Log().Debugf("[relay] Replaced From email address with %s", address)
|
||||
}
|
||||
} else {
|
||||
// no From header, so add one
|
||||
msg = append([]byte("From: "+address+"\r\n"), msg...)
|
||||
logger.Log().Debugf("[relay] Added From email: %s", address)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
|
||||
@@ -17,3 +17,34 @@ func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("%s not found", key)
|
||||
}
|
||||
|
||||
// SetHTMLAttributeVal sets an attribute on a node.
|
||||
func SetHTMLAttributeVal(n *html.Node, key, val string) {
|
||||
for i := range n.Attr {
|
||||
a := &n.Attr[i]
|
||||
if a.Key == key {
|
||||
a.Val = val
|
||||
return
|
||||
}
|
||||
}
|
||||
n.Attr = append(n.Attr, html.Attribute{
|
||||
Key: key,
|
||||
Val: val,
|
||||
})
|
||||
}
|
||||
|
||||
// WalkHTML traverses the entire HTML tree and calls fn on each node.
|
||||
func WalkHTML(n *html.Node, fn func(*html.Node)) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fn(n)
|
||||
|
||||
// Each node has a pointer to its first child and next sibling. To traverse
|
||||
// all children of a node, we need to start from its first child and then
|
||||
// traverse the next sibling until nil.
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
WalkHTML(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
1588
package-lock.json
generated
1588
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"build": "MINIFY=true node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
@@ -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",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,8 @@ type webUIConfiguration struct {
|
||||
AllowedRecipients string
|
||||
// Block relaying to these recipients (regex)
|
||||
BlockedRecipients string
|
||||
// Overrides the "From" address for all relayed messages
|
||||
OverrideFrom string
|
||||
// DEPRECATED 2024/03/12
|
||||
// swagger:ignore
|
||||
RecipientAllowlist string
|
||||
@@ -67,8 +70,14 @@ type webUIConfiguration struct {
|
||||
// Whether SpamAssassin is enabled
|
||||
SpamAssassin bool
|
||||
|
||||
// Whether Chaos support is enabled at runtime
|
||||
ChaosEnabled bool
|
||||
|
||||
// Whether messages with duplicate IDs are ignored
|
||||
DuplicatesIgnored bool
|
||||
|
||||
// Whether the delete button should be hidden
|
||||
HideDeleteAllButton bool
|
||||
}
|
||||
|
||||
// Web UI configuration response
|
||||
@@ -107,12 +116,15 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
110
server/apiv1/chaos.go
Normal file
110
server/apiv1/chaos.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
// ChaosTriggers are the Chaos triggers
|
||||
type ChaosTriggers chaos.Triggers
|
||||
|
||||
// Response for the Chaos triggers configuration
|
||||
// swagger:response ChaosResponse
|
||||
type chaosResponse struct {
|
||||
// The current Chaos triggers
|
||||
//
|
||||
// in: body
|
||||
Body ChaosTriggers
|
||||
}
|
||||
|
||||
// GetChaos returns the current Chaos triggers
|
||||
func GetChaos(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/chaos testing getChaos
|
||||
//
|
||||
// # Get Chaos triggers
|
||||
//
|
||||
// Returns the current Chaos triggers configuration.
|
||||
// This API route will return an error if Chaos is not enabled at runtime.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ChaosResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
if !chaos.Enabled {
|
||||
httpError(w, "Chaos is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
conf := chaos.Config
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters setChaosParams
|
||||
type setChaosParams struct {
|
||||
// in: body
|
||||
Body ChaosTriggers
|
||||
}
|
||||
|
||||
// SetChaos sets the Chaos configuration.
|
||||
func SetChaos(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/chaos testing setChaosParams
|
||||
//
|
||||
// # Set Chaos triggers
|
||||
//
|
||||
// Set the Chaos triggers configuration and return the updated values.
|
||||
// This API route will return an error if Chaos is not enabled at runtime.
|
||||
//
|
||||
// If any triggers are omitted from the request, then those are reset to their
|
||||
// default values with a 0% probability (ie: disabled).
|
||||
// Setting a blank `{}` will reset all triggers to their default values.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ChaosResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
if !chaos.Enabled {
|
||||
httpError(w, "Chaos is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
data := chaos.Triggers{}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := chaos.SetFromStruct(data); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
conf := chaos.Config
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,16 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
|
||||
@@ -53,6 +53,9 @@ type MessagesSummary struct {
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
|
||||
// Total number of unread messages matching current query
|
||||
MessagesUnreadCount float64 `json:"messages_unread"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
@@ -100,6 +103,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -326,6 +355,14 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
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 = float64(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
|
||||
@@ -30,7 +30,7 @@ type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheckParams
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check other HTMLCheckParams
|
||||
//
|
||||
// # HTML check
|
||||
//
|
||||
@@ -114,7 +114,7 @@ type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
// LinkCheck returns a summary of links in the email
|
||||
func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheckParams
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check other LinkCheckParams
|
||||
//
|
||||
// # Link check
|
||||
//
|
||||
@@ -184,7 +184,7 @@ type SpamAssassinResponse = spamassassin.Result
|
||||
|
||||
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
|
||||
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheckParams
|
||||
// swagger:route GET /api/v1/message/{ID}/sa-check other SpamAssassinCheckParams
|
||||
//
|
||||
// # SpamAssassin check
|
||||
//
|
||||
|
||||
@@ -133,7 +133,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fromAddresses, err := m.Header.AddressList("From")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
httpError(w, "Failed: unable to parse From header: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// update message date
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
|
||||
msg, err = tools.SetMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -178,8 +178,8 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// generate unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
// update Message-Id with unique ID
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
|
||||
// update Message-ID with unique ID
|
||||
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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/gorilla/mux"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// swagger:parameters GetMessageHTMLParams
|
||||
@@ -18,6 +23,17 @@ type getMessageHTMLParams struct {
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target="_blank"` and `rel="noreferrer noopener"` to all links.
|
||||
//
|
||||
// In addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.
|
||||
//
|
||||
// Note that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type: string
|
||||
Embed string `json:"embed"`
|
||||
}
|
||||
|
||||
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
|
||||
@@ -68,9 +84,43 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
html := linkInlineImages(msg)
|
||||
htmlStr := linkInlineImages(msg)
|
||||
|
||||
// If embed=1 is set, then we will add target="_blank" and rel="noreferrer noopener" to all links
|
||||
if r.URL.Query().Get("embed") == "1" {
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
} else {
|
||||
// Walk the entire HTML tree.
|
||||
tools.WalkHTML(doc, func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.DataAtom == atom.A {
|
||||
// Set attributes on all anchors with external links.
|
||||
tools.SetHTMLAttributeVal(n, "target", "_blank")
|
||||
tools.SetHTMLAttributeVal(n, "rel", "noreferrer noopener")
|
||||
}
|
||||
})
|
||||
|
||||
b := bytes.Buffer{}
|
||||
_ = html.Render(&b, doc)
|
||||
htmlStr = b.String()
|
||||
|
||||
nonce := r.Header.Get("mp-nonce")
|
||||
|
||||
js := `<script nonce="` + nonce + `">
|
||||
if (typeof window.parent == "object") {
|
||||
window.addEventListener('load', function () {
|
||||
window.parent.postMessage({ messageHeight: document.body.scrollHeight}, "*")
|
||||
})
|
||||
}
|
||||
</script>`
|
||||
|
||||
htmlStr = strings.ReplaceAll(htmlStr, "</body>", js+"</body>")
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
_, _ = w.Write([]byte(htmlStr))
|
||||
}
|
||||
|
||||
// swagger:parameters GetMessageTextParams
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
81
server/embed.go
Normal file
81
server/embed.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed ui
|
||||
distFS embed.FS
|
||||
)
|
||||
|
||||
// EmbedController is a simple controller to return a file from the embedded filesystem.
|
||||
//
|
||||
// This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes
|
||||
// the Content-Encoding header from error responses, breaking pages such as 404's while
|
||||
// using gzip compression middleware.
|
||||
func embedController(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
|
||||
if strings.HasSuffix(p, "/") {
|
||||
p = p + "index.html"
|
||||
}
|
||||
|
||||
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 {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// ensure any HTML files have the correct nonce
|
||||
if strings.HasSuffix(p, ".html") {
|
||||
nonce := r.Header.Get("mp-nonce")
|
||||
b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce))
|
||||
}
|
||||
|
||||
// allow browser cache except for ?dev queries and HTML files
|
||||
if r.URL.RawQuery != "dev" && !strings.HasSuffix(p, ".html") {
|
||||
w.Header().Set("Cache-Control", "max-age=31536000, public, immutable")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType(p))
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// ContentType supports only a few content types, limited to this application's needs.
|
||||
func contentType(p string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(p, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".css"):
|
||||
return "text/css; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".js"):
|
||||
return "application/javascript; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".json"):
|
||||
return "application/json"
|
||||
case strings.HasSuffix(p, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(p, ".ico"):
|
||||
return "image/x-icon"
|
||||
case strings.HasSuffix(p, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(p, ".jpg"):
|
||||
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:
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,12 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
@@ -30,11 +29,13 @@ import (
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
var embeddedFS embed.FS
|
||||
var (
|
||||
// AccessControlAllowOrigin CORS policy
|
||||
AccessControlAllowOrigin string
|
||||
|
||||
// AccessControlAllowOrigin CORS policy
|
||||
var AccessControlAllowOrigin string
|
||||
// htmlPreviewRouteRe is a regexp to match the HTML preview route
|
||||
htmlPreviewRouteRe *regexp.Regexp
|
||||
)
|
||||
|
||||
// Listen will start the httpd
|
||||
func Listen() {
|
||||
@@ -42,12 +43,6 @@ func Listen() {
|
||||
isReady.Store(false)
|
||||
stats.Track()
|
||||
|
||||
serverRoot, err := fs.Sub(embeddedFS, "ui")
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[http] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
websockets.MessageHub = websockets.NewHub()
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
@@ -64,12 +59,12 @@ func Listen() {
|
||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||
|
||||
// virtual filesystem for /dist/ & some individual files
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
|
||||
|
||||
// redirect to webroot if no trailing slash
|
||||
if config.Webroot != "/" {
|
||||
@@ -183,6 +178,10 @@ func apiRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||
|
||||
// Chaos
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
@@ -229,7 +228,12 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Security-Policy", cspHeader)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
if htmlPreviewRouteRe == nil {
|
||||
htmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\.html$`)
|
||||
}
|
||||
|
||||
if AccessControlAllowOrigin != "" &&
|
||||
(strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI)) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
@@ -249,7 +253,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
|
||||
}
|
||||
@@ -262,44 +266,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MiddlewareHandler http middleware adds optional basic authentication
|
||||
// and gzip compression
|
||||
func middlewareHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.UICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to webroot
|
||||
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
@@ -313,7 +279,7 @@ func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
|
||||
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
|
||||
f, err := distFS.ReadFile("ui/api/v1/swagger.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -348,7 +314,7 @@ func index(w http.ResponseWriter, r *http.Request) {
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
|
||||
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
|
||||
You need a browser with JavaScript support to use Mailpit
|
||||
You need a browser with JavaScript enabled to use Mailpit
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
@@ -379,6 +345,6 @@ func index(w http.ResponseWriter, r *http.Request) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -37,7 +37,7 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
@@ -50,7 +50,7 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 messages
|
||||
@@ -61,17 +61,17 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// get RAW
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// get headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
@@ -111,7 +111,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 IDs
|
||||
@@ -134,11 +134,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
@@ -147,11 +147,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.Read = false
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
@@ -160,13 +160,13 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.IDs = []string{}
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
t.Log("Mark all read")
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
|
||||
}
|
||||
@@ -272,14 +272,14 @@ func TestAPIv1Send(t *testing.T) {
|
||||
resp := apiv1.SendMessageConfirmation{}
|
||||
|
||||
if err := json.Unmarshal(b, &resp); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("Fetching response for message %s", resp.ID)
|
||||
msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Testing response for message %s", resp.ID)
|
||||
@@ -307,7 +307,7 @@ func TestAPIv1Send(t *testing.T) {
|
||||
|
||||
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
@@ -331,12 +331,12 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
|
||||
data, err := clientGet(uri)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -352,12 +352,12 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
|
||||
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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 { RouterView } from 'vue-router'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
|
||||
export default {
|
||||
@@ -11,12 +11,12 @@ export default {
|
||||
|
||||
components: {
|
||||
Favicon,
|
||||
AppBadge,
|
||||
Notifications,
|
||||
EditTags
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
mailbox.uiConfig = response.data
|
||||
@@ -42,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;
|
||||
|
||||
@@ -228,27 +228,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 +351,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 +412,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 +419,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>
|
||||
@@ -83,17 +83,29 @@ export default {
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.messages_unread" @click="markAllRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
||||
<button 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 v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
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"
|
||||
: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>
|
||||
@@ -68,11 +89,29 @@ export default {
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button 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 v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
: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.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" />
|
||||
@@ -81,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">
|
||||
|
||||
@@ -12,6 +12,8 @@ export default {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,7 +25,23 @@ export default {
|
||||
localStorage.setItem('theme', v)
|
||||
}
|
||||
this.setTheme()
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
'mailbox.skipConfirmations'(v) {
|
||||
if (v) {
|
||||
localStorage.setItem('skip-confirmations', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('skip-confirmations')
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
@@ -31,6 +49,8 @@ export default {
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
|
||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -44,6 +64,24 @@ export default {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -54,64 +92,202 @@ export default {
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit UI settings</h5>
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select class="form-select" v-model="theme" id="theme">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone" data-allow-same="true">
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
||||
v-model="mailbox.showTagColors">
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
|
||||
aria-selected="true">Web UI</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
|
||||
aria-selected="false" @click="loadChaos">Chaos</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
|
||||
tabindex="0">
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select class="form-select" v-model="theme" id="theme">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
|
||||
data-allow-same="true">
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
||||
v-model="mailbox.showTagColors">
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck">
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck">
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck">
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<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
|
||||
<template v-if="!mailbox.uiConfig.HideDeleteAllButton">
|
||||
<code>Delete all</code> &
|
||||
</template>
|
||||
<code>Mark all read</code> confirmation dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck">
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
|
||||
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
|
||||
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
|
||||
stages in a SMTP transaction to test application resilience
|
||||
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
|
||||
see documentation
|
||||
</a>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Sender.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Recipient.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response.
|
||||
Note that SMTP authentication must be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
|
||||
max="599" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Authentication.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck">
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck">
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</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' : ''">
|
||||
|
||||
@@ -86,7 +86,10 @@ export default {
|
||||
<div class="row">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
|
||||
aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
|
||||
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''">
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,51 +125,39 @@ export default {
|
||||
Delete the message after release
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h6>Notes</h6>
|
||||
<ul>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''" class="form-text">
|
||||
A recipient <b>allowlist</b> has been configured. Any mail address not matching the following will be rejected:
|
||||
A recipient <b>allowlist</b> has been configured. Any mail address not matching the
|
||||
following will be rejected:
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</code>
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''" class="form-text">
|
||||
A recipient <b>blocklist</b> has been configured. Any mail address matching the following will be rejected:
|
||||
A recipient <b>blocklist</b> has been configured. Any mail address matching the following
|
||||
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>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||
The <code>From</code> email address has been overridden by the relay configuration to
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>.
|
||||
</li>
|
||||
<li class="form-text">
|
||||
SMTP delivery failures will bounce back to
|
||||
<code v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
|
||||
</code>
|
||||
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
||||
</code>
|
||||
<code v-else>{{ message.ReturnPath }}</code>.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- <div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
|
||||
Note: A recipient allowlist has been configured. Any mail address not matching it will be
|
||||
rejected.<br class="d-none d-md-inline">
|
||||
Allowed recipients: <b>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</b>
|
||||
</div>
|
||||
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''">
|
||||
Note: A recipient blocklist has been configured. Any mail address matching it will be
|
||||
rejected.<br class="d-none d-md-inline">
|
||||
Blocked recipients: <b>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</b>
|
||||
</div>
|
||||
<div class="form-text text-center">
|
||||
Note: For testing purposes, a unique Message-Id will be generated on send.
|
||||
<br class="d-none d-md-inline">
|
||||
SMTP delivery failures will bounce back to
|
||||
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
|
||||
</b>
|
||||
<b v-else>{{ message.ReturnPath }}</b>.
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ export const mailbox = reactive({
|
||||
searching: false, // current search, false for none
|
||||
refresh: false, // to listen from MessagesMixin
|
||||
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false,
|
||||
notificationsSupported: false, // browser supports notifications
|
||||
notificationsEnabled: false, // user has enabled notifications
|
||||
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
|
||||
appInfo: {}, // application information
|
||||
uiConfig: {}, // configuration for UI
|
||||
lastMessage: false, // return scrolling
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<link rel="icon" href="../../favicon.svg">
|
||||
<script src="../../dist/docs.js"></script>
|
||||
<script src="../../dist/docs.js" nonce="%%NONCE%%"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<rapi-doc id="thedoc" spec-url="swagger.json" theme="light" layout="column" render-style="read" load-fonts="false"
|
||||
allow-authentication="false"
|
||||
allow-authentication="false" sort-tags="true"
|
||||
regular-font="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'"
|
||||
mono-font="Courier New, Courier, System, fixed-width" font-size="large" allow-spec-url-load="false"
|
||||
allow-spec-file-load="false" allow-server-selection="false" allow-search="false" allow-advanced-search="false"
|
||||
|
||||
@@ -23,6 +23,66 @@
|
||||
"version": "v1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/chaos": {
|
||||
"get": {
|
||||
"description": "Returns the current Chaos triggers configuration.\nThis API route will return an error if Chaos is not enabled at runtime.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"testing"
|
||||
],
|
||||
"summary": "Get Chaos triggers",
|
||||
"operationId": "getChaos",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ChaosResponse"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Set the Chaos triggers configuration and return the updated values.\nThis API route will return an error if Chaos is not enabled at runtime.\n\nIf any triggers are omitted from the request, then those are reset to their\ndefault values with a 0% probability (ie: disabled).\nSetting a blank `{}` will reset all triggers to their default values.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"testing"
|
||||
],
|
||||
"summary": "Set Chaos triggers",
|
||||
"operationId": "setChaosParams",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ChaosTriggers"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ChaosResponse"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/info": {
|
||||
"get": {
|
||||
"description": "Returns basic runtime information, message totals and latest release version.",
|
||||
@@ -139,7 +199,7 @@
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
"other"
|
||||
],
|
||||
"summary": "HTML check",
|
||||
"operationId": "HTMLCheckParams",
|
||||
@@ -179,7 +239,7 @@
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
"other"
|
||||
],
|
||||
"summary": "Link check",
|
||||
"operationId": "LinkCheckParams",
|
||||
@@ -414,7 +474,7 @@
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
"other"
|
||||
],
|
||||
"summary": "SpamAssassin check",
|
||||
"operationId": "SpamAssassinCheckParams",
|
||||
@@ -488,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"
|
||||
],
|
||||
@@ -512,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"
|
||||
},
|
||||
@@ -527,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": {
|
||||
@@ -617,6 +690,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "0",
|
||||
"x-go-name": "Start",
|
||||
"description": "Pagination offset",
|
||||
"name": "start",
|
||||
@@ -624,6 +698,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "50",
|
||||
"x-go-name": "Limit",
|
||||
"description": "Limit results",
|
||||
"name": "limit",
|
||||
@@ -632,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"
|
||||
}
|
||||
@@ -942,6 +1017,13 @@
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"x-go-name": "Embed",
|
||||
"description": "If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target=\"_blank\"` and `rel=\"noreferrer noopener\"` to all links.\n\nIn addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.\n\nNote that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.",
|
||||
"name": "embed",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1121,6 +1203,10 @@
|
||||
},
|
||||
"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",
|
||||
@@ -1350,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",
|
||||
@@ -1398,6 +1510,9 @@
|
||||
"$ref": "#/definitions/Attachment"
|
||||
}
|
||||
},
|
||||
"ListUnsubscribe": {
|
||||
"$ref": "#/definitions/ListUnsubscribe"
|
||||
},
|
||||
"MessageID": {
|
||||
"description": "Message ID",
|
||||
"type": "string"
|
||||
@@ -1553,6 +1668,12 @@
|
||||
"format": "double",
|
||||
"x-go-name": "MessagesCount"
|
||||
},
|
||||
"messages_unread": {
|
||||
"description": "Total number of unread messages matching current query",
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"x-go-name": "MessagesUnreadCount"
|
||||
},
|
||||
"start": {
|
||||
"description": "Pagination offset",
|
||||
"type": "integer",
|
||||
@@ -1816,10 +1937,53 @@
|
||||
"x-go-name": "Result",
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
|
||||
},
|
||||
"Trigger": {
|
||||
"description": "Trigger for Chaos",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ErrorCode",
|
||||
"Probability"
|
||||
],
|
||||
"properties": {
|
||||
"ErrorCode": {
|
||||
"description": "SMTP error code to return. The value must range from 400 to 599.",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"example": 451
|
||||
},
|
||||
"Probability": {
|
||||
"description": "Probability (chance) of triggering the error. The value must range from 0 to 100.",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"example": 5
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
},
|
||||
"Triggers": {
|
||||
"description": "Triggers for the Chaos configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Authentication": {
|
||||
"$ref": "#/definitions/Trigger"
|
||||
},
|
||||
"Recipient": {
|
||||
"$ref": "#/definitions/Trigger"
|
||||
},
|
||||
"Sender": {
|
||||
"$ref": "#/definitions/Trigger"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
},
|
||||
"WebUIConfiguration": {
|
||||
"description": "Response includes global web UI settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ChaosEnabled": {
|
||||
"description": "Whether Chaos support is enabled at runtime",
|
||||
"type": "boolean"
|
||||
},
|
||||
"DuplicatesIgnored": {
|
||||
"description": "Whether messages with duplicate IDs are ignored",
|
||||
"type": "boolean"
|
||||
@@ -1844,6 +2008,10 @@
|
||||
"description": "Whether message relaying (release) is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"OverrideFrom": {
|
||||
"description": "Overrides the \"From\" address for all relayed messages",
|
||||
"type": "string"
|
||||
},
|
||||
"ReturnPath": {
|
||||
"description": "Enforced Return-Path (if set) for relay bounces",
|
||||
"type": "string"
|
||||
@@ -1885,6 +2053,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ChaosResponse": {
|
||||
"description": "Response for the Chaos triggers configuration",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ChaosTriggers"
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"description": "Server error will return with a 400 status code\nwith the error message in the body",
|
||||
"schema": {
|
||||
|
||||
Reference in New Issue
Block a user