mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-04 14:47:00 +00:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f0515b48 | ||
|
|
88e1aa324b | ||
|
|
a7e27ea9b7 | ||
|
|
796749e1a1 | ||
|
|
91e4a87995 | ||
|
|
3ad7623e84 | ||
|
|
f4954ba115 | ||
|
|
4195f30d95 | ||
|
|
690e82cbfd | ||
|
|
2d42c87285 | ||
|
|
c208d71a33 | ||
|
|
3cacede2d7 | ||
|
|
1886277b6e | ||
|
|
3fff79e29f | ||
|
|
7dee371721 | ||
|
|
95e3ef6fca | ||
|
|
f88a42fda4 | ||
|
|
3aae06ff6b | ||
|
|
4b5ce0afed | ||
|
|
d4ee6fd987 | ||
|
|
29f4a10d89 | ||
|
|
8f7bf25022 | ||
|
|
a1110e5ad8 | ||
|
|
31a2ed8824 | ||
|
|
f675ef7b5e | ||
|
|
4257a89584 | ||
|
|
52957cd81f | ||
|
|
1520143c45 | ||
|
|
5107ce0191 | ||
|
|
40afef8ffd | ||
|
|
fed20de522 | ||
|
|
6999b2ea02 | ||
|
|
72e92d2d1e | ||
|
|
803adf29ac | ||
|
|
fb0230a460 | ||
|
|
873193bcec | ||
|
|
e3538cb86a | ||
|
|
e6ab9e1008 | ||
|
|
86f3546bfe | ||
|
|
a6b5f5f76b | ||
|
|
82d7bdc971 | ||
|
|
020d5b0fcb | ||
|
|
f2b91ac9d5 | ||
|
|
4dff7adc1d | ||
|
|
9bfdeb5f7b | ||
|
|
c5b3edf87d | ||
|
|
8c59229f97 | ||
|
|
56739ceac2 | ||
|
|
5240b1b33e | ||
|
|
8f80a57c3c | ||
|
|
04ea905619 | ||
|
|
b84b428434 | ||
|
|
91409310d7 | ||
|
|
99a3e17243 | ||
|
|
ff272d1c5e | ||
|
|
74c6a0a434 | ||
|
|
e16267ab50 | ||
|
|
8d86b39385 | ||
|
|
38914348a5 | ||
|
|
25580b9a68 | ||
|
|
a1c2690c44 | ||
|
|
ff8b6326ab | ||
|
|
5d2966d726 | ||
|
|
bf5609a39b | ||
|
|
4ed5011a8f | ||
|
|
68d911431f | ||
|
|
d0716b4995 | ||
|
|
84a519e84d | ||
|
|
e1a6904eca | ||
|
|
bc200c663f | ||
|
|
009f3a8fd9 | ||
|
|
cfe695c35d | ||
|
|
5eb77cbb18 | ||
|
|
5ab8486a6c | ||
|
|
8691afd850 | ||
|
|
3517ec42c9 | ||
|
|
9dada2fd30 | ||
|
|
84a7d8b30d | ||
|
|
a50d80b5fc | ||
|
|
5e1a228328 | ||
|
|
b4f4b857f3 | ||
|
|
658c94a2d1 | ||
|
|
05375fed7a | ||
|
|
5961bf000d | ||
|
|
87c67e1b1f | ||
|
|
c79abb3e5f | ||
|
|
8ce5a35e3b | ||
|
|
dd0ba8b09d | ||
|
|
f18b8f8fb1 | ||
|
|
c76adb8c01 | ||
|
|
1b95f2fe39 | ||
|
|
1400936760 | ||
|
|
04289091bc | ||
|
|
6acbbb4446 | ||
|
|
bc9a5cd4c2 | ||
|
|
85a2c1502a | ||
|
|
15de95ff62 | ||
|
|
019613004d | ||
|
|
c204339dbb | ||
|
|
981ccd2a74 | ||
|
|
20b2eb22d4 | ||
|
|
6c0ef5ba33 | ||
|
|
2dbc4ea601 | ||
|
|
54b6d8f85c | ||
|
|
5e84633e76 | ||
|
|
7fbff71689 | ||
|
|
164e7c150d | ||
|
|
d87e3087f3 | ||
|
|
56ca3afbad | ||
|
|
b7fa68dff9 | ||
|
|
5214739618 | ||
|
|
2bb2036380 | ||
|
|
de693c9c68 | ||
|
|
bb5ea68f03 | ||
|
|
b4131dbeae | ||
|
|
e3e1d734b6 | ||
|
|
25671ba94f | ||
|
|
290ffdd80c | ||
|
|
753591105a | ||
|
|
5d0bbe74e0 | ||
|
|
ff1751350f | ||
|
|
fdd3cb3074 | ||
|
|
4f81fb417f | ||
|
|
39886cf57c | ||
|
|
9a1f3a6bb5 | ||
|
|
ac9b7de295 | ||
|
|
d4406cf02b | ||
|
|
577461bff4 | ||
|
|
289466bdb8 | ||
|
|
3c2e227d32 | ||
|
|
7dfdf54e97 | ||
|
|
f61a390bd9 | ||
|
|
b827d75c3e | ||
|
|
784e3de8a1 | ||
|
|
876d0eb5da | ||
|
|
6e9760d5d9 | ||
|
|
aafd2a20d9 | ||
|
|
284e66f0ba | ||
|
|
8995cddfa5 | ||
|
|
8401ffff22 | ||
|
|
a6d0db174b | ||
|
|
c7d7810e68 | ||
|
|
d26e317d25 | ||
|
|
a051fd49a9 | ||
|
|
f836e92d58 | ||
|
|
1db502ef4e | ||
|
|
703e981a8b | ||
|
|
8878ece19f | ||
|
|
7c366669c7 | ||
|
|
61a1ed0e49 | ||
|
|
9b2e90279d | ||
|
|
a1d35d488d | ||
|
|
a3bd62482d | ||
|
|
d0458e2e7a |
@@ -1,47 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
{{ if .Versions -}}
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
## [Unreleased]
|
||||
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
{{ range .Unreleased.CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
## [{{ .Tag.Name }}]
|
||||
|
||||
{{ if .NoteGroups -}}
|
||||
{{ range .NoteGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Notes }}
|
||||
{{ .Body }}
|
||||
{{ end -}}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{- if .MergeCommits -}}
|
||||
### Pull Requests
|
||||
{{ range .MergeCommits -}}
|
||||
- {{ .Header }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
@@ -1,12 +0,0 @@
|
||||
{{ if .Versions -}}
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
@@ -1,46 +0,0 @@
|
||||
style: github
|
||||
template: CHANGELOG.tpl.md
|
||||
info:
|
||||
title: CHANGELOG
|
||||
repository_url: https://github.com/axllent/mailpit
|
||||
options:
|
||||
commits:
|
||||
# filters:
|
||||
# Type:
|
||||
# - feat
|
||||
# - fix
|
||||
# - perf
|
||||
# - refactor
|
||||
commit_groups:
|
||||
title_maps:
|
||||
feature: Feature
|
||||
fix: Fix
|
||||
# perf: Performance Improvements
|
||||
# refactor: Code Refactoring
|
||||
sort_by: Custom
|
||||
title_order:
|
||||
- Feature
|
||||
- Chore
|
||||
- UI
|
||||
- API
|
||||
- Libs
|
||||
- Docker
|
||||
- Security
|
||||
- Fix
|
||||
- Bugfix
|
||||
- Docs
|
||||
- Swagger
|
||||
- Build
|
||||
- Testing
|
||||
- Test
|
||||
- Tests
|
||||
- Pull Requests
|
||||
header:
|
||||
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
|
||||
pattern_maps:
|
||||
- Type
|
||||
- Scope
|
||||
- Subject
|
||||
notes:
|
||||
keywords:
|
||||
- BREAKING CHANGE
|
||||
0
SECURITY.md → .github/SECURITY.md
vendored
0
SECURITY.md → .github/SECURITY.md
vendored
48
.github/cliff.toml
vendored
Normal file
48
.github/cliff.toml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
## https://git-cliff.org/
|
||||
[changelog]
|
||||
body = """
|
||||
{% if version %}\
|
||||
\n## [{{ version }}]
|
||||
{% else %}\
|
||||
\n## Unreleased
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}\
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = ""
|
||||
header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file."
|
||||
postprocessors = [
|
||||
{pattern = "reponse", replace = "response"},
|
||||
{pattern = "messsage", replace = "message"},
|
||||
{pattern = '(?i) go modules', replace = " Go dependencies"},
|
||||
{pattern = '(?i) node modules', replace = " node dependencies"},
|
||||
{pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"},
|
||||
]
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# HTML comments added for grouping order, stripped on generation
|
||||
commit_parsers = [
|
||||
{body = ".*security", group = "<!-- 1 -->Security"},
|
||||
{message = "(?i)^feat", group = "<!-- 2 -->Feature"},
|
||||
{message = "(?i)^chore", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^libs", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^ui", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^api", group = "<!-- 4 -->API"},
|
||||
{message = "(?i)^fix", group = "<!-- 5 -->Fix"},
|
||||
{message = "(?i)^doc", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^swagger", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^test", group = "<!-- 7 -->Test"},
|
||||
]
|
||||
|
||||
# Exclude commits that are not matched by any commit parser.
|
||||
# filter_commits = true
|
||||
# Order releases topologically instead of chronologically.
|
||||
# topo_order = true
|
||||
# Order of commits in each group/release within the changelog.
|
||||
# Allowed values: newest, oldest
|
||||
sort_commits = "oldest"
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -48,6 +48,6 @@ jobs:
|
||||
axllent/mailpit:latest
|
||||
axllent/mailpit:${{ github.ref_name }}
|
||||
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
2
.github/workflows/release-build.yml
vendored
2
.github/workflows/release-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
- run: echo "Building assets for ${{ github.ref_name }}"
|
||||
- run: npm install
|
||||
|
||||
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Tests (rqlite)
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
|
||||
jobs:
|
||||
test-rqlite:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
rqlite:
|
||||
image: rqlite/rqlite:latest
|
||||
ports:
|
||||
- 4001:4001
|
||||
env:
|
||||
# the HTTP address the rqlite node should advertise
|
||||
HTTP_ADV_ADDR: "localhost:4001"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache-dependency-path: "**/*.sum"
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
env:
|
||||
# set Mailpit to use the rqlite service container
|
||||
MP_DATABASE: "http://localhost:4001"
|
||||
27
.github/workflows/tests.yml
vendored
27
.github/workflows/tests.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.23']
|
||||
go-version: [stable]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: false
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Go tests
|
||||
- name: Set up Go environment
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -26,19 +26,30 @@ 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 ./internal/html2text -bench=.
|
||||
- name: Test Go linting (gofmt)
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
# https://olegk.dev/github-actions-and-go
|
||||
run: gofmt -s -w . && git diff --exit-code
|
||||
- name: Run Go tests
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
- name: Run Go benchmarking
|
||||
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
- name: Build web UI
|
||||
- name: Set up node environment
|
||||
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
|
||||
- name: Install JavaScript dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm install
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
- name: Run JavaScript linting
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run lint
|
||||
- name: Test JavaScript packaging
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run package
|
||||
|
||||
# validate the swagger file
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Not within the scope of Prettier
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.json
|
||||
**/*.md
|
||||
**/*.css
|
||||
**/*.html
|
||||
**/*.scss
|
||||
composer.lock
|
||||
39
.vscode/settings.json
vendored
Normal file
39
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": [
|
||||
"AUTHCRAMMD",
|
||||
"AUTHLOGIN",
|
||||
"AUTHPLAIN",
|
||||
"bordercolor",
|
||||
"CRAMMD",
|
||||
"dateparse",
|
||||
"EHLO",
|
||||
"ESMTP",
|
||||
"EXPN",
|
||||
"gofmt",
|
||||
"Healthz",
|
||||
"HTTPIP",
|
||||
"Inlines",
|
||||
"jhillyerd",
|
||||
"leporo",
|
||||
"lithammer",
|
||||
"livez",
|
||||
"Mechs",
|
||||
"navhtml",
|
||||
"neostandard",
|
||||
"popperjs",
|
||||
"readyz",
|
||||
"RSET",
|
||||
"shortuuid",
|
||||
"SMTPTLS",
|
||||
"swaggerexpert",
|
||||
"UITLS",
|
||||
"VRFY",
|
||||
"writef"
|
||||
]
|
||||
}
|
||||
1027
CHANGELOG.md
1027
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
61
CONTRIBUTING.md
Normal file
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Contributing guide
|
||||
|
||||
Thank you for your interest in contributing to Mailpit, your help is greatly appreciated! Please follow the guidelines below to ensure a smooth contribution process.
|
||||
|
||||
|
||||
## Code of conduct
|
||||
|
||||
Please be respectful and considerate in all interactions. Mailpit is open source and free of charge, however is the result of thousands of hours of work.
|
||||
|
||||
|
||||
## Reporting issues and feature requests
|
||||
|
||||
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Pleas do not report security issues here (see below).
|
||||
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Please do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.
|
||||
|
||||
|
||||
|
||||
## How to contribute (pull request)
|
||||
|
||||
1. **Fork the repository**
|
||||
Click the "Fork" button at the top right of this repository to create your own copy.
|
||||
|
||||
2. **Clone your fork**
|
||||
```bash
|
||||
git clone https://github.com/your-username/mailpit.git
|
||||
cd mailpit
|
||||
```
|
||||
|
||||
3. **Create a branch**
|
||||
Use a descriptive branch name:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
4. **Make your changes**
|
||||
Write clear, concise code and include comments where necessary.
|
||||
|
||||
5. **Test your changes**
|
||||
Run all tests to ensure nothing is broken. This is a mandatory step as pull requests cannot be merged unless they pass the automated testing.
|
||||
|
||||
6. **Ensure your changes pass linting**
|
||||
Ensure your changes pass the [code linting](https://mailpit.axllent.org/docs/development/code-linting/) requirements. This is a mandatory step as pull requests cannot be merged unless they pass the automated linting tests.
|
||||
|
||||
7. **Commit and push**
|
||||
Write a clear commit message:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Describe your changes"
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
8. **Open a pull request**
|
||||
Go to your fork on GitHub and open a pull request against the `develop` branch. Fill out the PR template and describe your changes.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make this project awesome!
|
||||
@@ -25,6 +25,6 @@ RUN apk upgrade --no-cache && apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 1110/tcp 8025/tcp
|
||||
|
||||
HEALTHCHECK --interval=15s CMD /mailpit readyz
|
||||
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
|
||||
12
README.md
12
README.md
@@ -68,12 +68,18 @@ Mailpit runs as a single binary and can be installed in different ways:
|
||||
- **FreeBSD**: `pkg install mailpit`
|
||||
|
||||
|
||||
### Install via bash script (Linux & Mac)
|
||||
### Install via script (Linux & Mac)
|
||||
|
||||
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
|
||||
|
||||
```bash
|
||||
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```shell
|
||||
sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
You can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:
|
||||
|
||||
```shell
|
||||
INSTALL_PATH=/usr/bin sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
|
||||
|
||||
36
cmd/dump.go
Normal file
36
cmd/dump.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/dump"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// dumpCmd represents the dump command
|
||||
var dumpCmd = &cobra.Command{
|
||||
Use: "dump <database> <output-dir>",
|
||||
Short: "Dump all messages from a database to a directory",
|
||||
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
|
||||
|
||||
The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a
|
||||
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
|
||||
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := dump.Sync(args[0]); err != nil {
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(dumpCmd)
|
||||
|
||||
dumpCmd.Flags().SortFlags = false
|
||||
|
||||
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
|
||||
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
|
||||
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
|
||||
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
}
|
||||
58
cmd/root.go
58
cmd/root.go
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
@@ -39,6 +40,14 @@ Documentation:
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start Prometheus metrics if enabled
|
||||
switch prometheus.GetMode() {
|
||||
case "integrated":
|
||||
prometheus.StartUpdater()
|
||||
case "separate":
|
||||
go prometheus.StartSeparateServer()
|
||||
}
|
||||
|
||||
go server.Listen()
|
||||
|
||||
if err := smtpd.Listen(); err != nil {
|
||||
@@ -83,12 +92,15 @@ 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().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
|
||||
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
|
||||
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
@@ -103,6 +115,12 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
|
||||
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.HideDeleteAllButton, "hide-delete-all-button", config.HideDeleteAllButton, "Hide the \"Delete all\" button in the web UI")
|
||||
|
||||
// Send API
|
||||
rootCmd.Flags().StringVar(&config.SendAPIAuthFile, "send-api-auth-file", config.SendAPIAuthFile, "A password file for Send API authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, "send-api-auth-accept-any", config.SendAPIAuthAcceptAny, "Accept any username and password for the Send API endpoint, including none")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
@@ -141,6 +159,10 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
|
||||
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
|
||||
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
|
||||
rootCmd.Flags().BoolVar(&config.TagsUsername, "tags-username", config.TagsUsername, "Auto-tag messages with the authenticated username")
|
||||
|
||||
// Prometheus metrics
|
||||
rootCmd.Flags().StringVar(&config.PrometheusListen, "enable-prometheus", config.PrometheusListen, "Enable Prometheus metrics: true|false|<ip:port> (eg:'0.0.0.0:9090')")
|
||||
|
||||
// Webhook
|
||||
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
|
||||
@@ -181,6 +203,14 @@ func initConfigFromEnv() {
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
}
|
||||
|
||||
config.DisableWAL = getEnabledFromEnv("MP_DISABLE_WAL")
|
||||
|
||||
config.DisableVersionCheck = getEnabledFromEnv("MP_DISABLE_VERSION_CHECK")
|
||||
|
||||
if len(os.Getenv("MP_COMPRESSION")) > 0 {
|
||||
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
|
||||
}
|
||||
|
||||
config.TenantID = os.Getenv("MP_TENANT_ID")
|
||||
|
||||
config.Label = os.Getenv("MP_LABEL")
|
||||
@@ -232,6 +262,21 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||
config.AllowUntrustedTLS = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTTP_COMPRESSION") {
|
||||
config.DisableHTTPCompression = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_HIDE_DELETE_ALL_BUTTON") {
|
||||
config.HideDeleteAllButton = true
|
||||
}
|
||||
|
||||
// Send API
|
||||
config.SendAPIAuthFile = os.Getenv("MP_SEND_API_AUTH_FILE")
|
||||
if err := auth.SetSendAPIAuth(os.Getenv("MP_SEND_API_AUTH")); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SEND_API_AUTH_ACCEPT_ANY") {
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
@@ -280,6 +325,7 @@ func initConfigFromEnv() {
|
||||
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
|
||||
}
|
||||
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
|
||||
config.SMTPRelayConfig.TLS = getEnabledFromEnv("MP_SMTP_RELAY_TLS")
|
||||
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
|
||||
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
|
||||
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
|
||||
@@ -289,6 +335,7 @@ func initConfigFromEnv() {
|
||||
config.SMTPRelayConfig.OverrideFrom = os.Getenv("MP_SMTP_RELAY_OVERRIDE_FROM")
|
||||
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv("MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
@@ -298,6 +345,7 @@ func initConfigFromEnv() {
|
||||
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
|
||||
}
|
||||
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
|
||||
config.SMTPForwardConfig.TLS = getEnabledFromEnv("MP_SMTP_FORWARD_TLS")
|
||||
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
|
||||
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
|
||||
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
|
||||
@@ -327,6 +375,12 @@ func initConfigFromEnv() {
|
||||
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
|
||||
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
|
||||
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
|
||||
config.TagsUsername = getEnabledFromEnv("MP_TAGS_USERNAME")
|
||||
|
||||
// Prometheus metrics
|
||||
if len(os.Getenv("MP_ENABLE_PROMETHEUS")) > 0 {
|
||||
config.PrometheusListen = os.Getenv("MP_ENABLE_PROMETHEUS")
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
@@ -344,9 +398,9 @@ func initConfigFromEnv() {
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
logger.Log().Warn("ENV MP_DATA_FILE has been deprecated, use MP_DATABASE")
|
||||
config.Database = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
|
||||
@@ -28,6 +28,14 @@ var (
|
||||
// Database for mail (optional)
|
||||
Database string
|
||||
|
||||
// DisableWAL will disable Write-Ahead Logging in SQLite
|
||||
// @see https://sqlite.org/wal.html
|
||||
DisableWAL bool
|
||||
|
||||
// Compression is the compression level used to store raw messages in the database:
|
||||
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
|
||||
Compression = 1
|
||||
|
||||
// TenantID is an optional prefix to be applied to all database tables,
|
||||
// allowing multiple isolated instances of Mailpit to share a database.
|
||||
TenantID string
|
||||
@@ -61,6 +69,15 @@ var (
|
||||
// Webroot to define the base path for the UI and API
|
||||
Webroot = "/"
|
||||
|
||||
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
|
||||
DisableHTTPCompression bool
|
||||
|
||||
// SendAPIAuthFile for Send API authentication
|
||||
SendAPIAuthFile string
|
||||
|
||||
// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none
|
||||
SendAPIAuthAcceptAny bool
|
||||
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
@@ -112,6 +129,9 @@ var (
|
||||
// including x-tags & plus-addresses
|
||||
TagsDisable string
|
||||
|
||||
// TagsUsername enables auto-tagging messages with the authenticated username
|
||||
TagsUsername bool
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
@@ -162,6 +182,9 @@ var (
|
||||
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||
EnableSpamAssassin string
|
||||
|
||||
// HideDeleteAllButton hides the delete all button in the web UI
|
||||
HideDeleteAllButton bool
|
||||
|
||||
// WebhookURL for calling
|
||||
WebhookURL string
|
||||
|
||||
@@ -171,6 +194,10 @@ var (
|
||||
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
|
||||
AllowUntrustedTLS bool
|
||||
|
||||
// PrometheusListen address for Prometheus metrics server
|
||||
// Empty = disabled, true= use existing web server, address = separate server
|
||||
PrometheusListen string
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
@@ -186,6 +213,9 @@ var (
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// DisableVersionCheck disables version checking
|
||||
DisableVersionCheck bool
|
||||
|
||||
// DemoMode disables SMTP relay, link checking & HTTP send functionality
|
||||
DemoMode = false
|
||||
)
|
||||
@@ -198,10 +228,11 @@ type autoTag struct {
|
||||
|
||||
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
|
||||
type SMTPRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
@@ -212,6 +243,7 @@ type SMTPRelayConfigStruct struct {
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
|
||||
PreserveMessageIDs bool `yaml:"preserve-message-ids"` // preserve the original Message-ID when relaying
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
@@ -223,7 +255,8 @@ type SMTPForwardConfigStruct struct {
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
@@ -250,6 +283,10 @@ func VerifyConfig() error {
|
||||
Database = filepath.Join(Database, "mailpit.db")
|
||||
}
|
||||
|
||||
if Compression < 0 || Compression > 3 {
|
||||
return errors.New("[db] compression level must be between 0 and 3")
|
||||
}
|
||||
|
||||
Label = tools.Normalize(Label)
|
||||
|
||||
if err := parseMaxAge(); err != nil {
|
||||
@@ -269,6 +306,7 @@ func VerifyConfig() error {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
// Web UI & API
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
@@ -303,8 +341,51 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Send API
|
||||
if SendAPIAuthFile != "" {
|
||||
SendAPIAuthFile = filepath.Clean(SendAPIAuthFile)
|
||||
|
||||
if !isFile(SendAPIAuthFile) {
|
||||
return fmt.Errorf("[send-api] password file not found or readable: %s", SendAPIAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SendAPIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetSendAPIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Info("[send-api] enabling basic authentication")
|
||||
}
|
||||
|
||||
if auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {
|
||||
return errors.New("[send-api] authentication cannot use both credentials and --send-api-auth-accept-any")
|
||||
}
|
||||
|
||||
if SendAPIAuthAcceptAny && auth.UICredentials != nil {
|
||||
logger.Log().Info("[send-api] disabling authentication")
|
||||
}
|
||||
|
||||
// Prometheus configuration validation
|
||||
if PrometheusListen != "" {
|
||||
mode := strings.ToLower(strings.TrimSpace(PrometheusListen))
|
||||
if mode != "true" && mode != "false" {
|
||||
// Validate as address for separate server mode
|
||||
_, err := net.ResolveTCPAddr("tcp", PrometheusListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[prometheus] %s", err.Error())
|
||||
}
|
||||
} else if mode == "true" {
|
||||
logger.Log().Info("[prometheus] enabling metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
|
||||
return errors.New("[smtp] you must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" {
|
||||
@@ -389,7 +470,7 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
|
||||
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
|
||||
return errors.New("[pop3] you must provide both a POP3 TLS certificate and a key")
|
||||
}
|
||||
if POP3Listen != "" {
|
||||
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
@@ -145,6 +145,10 @@ func validateRelayConfig() error {
|
||||
SMTPRelayConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.STARTTLS && SMTPRelayConfig.TLS {
|
||||
return fmt.Errorf("[relay] TLS & STARTTLS cannot be required together")
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
@@ -243,6 +247,10 @@ func validateForwardConfig() error {
|
||||
SMTPForwardConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.STARTTLS && SMTPForwardConfig.TLS {
|
||||
return fmt.Errorf("[forward] TLS & STARTTLS cannot be required together")
|
||||
}
|
||||
|
||||
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import pluginVue from 'esbuild-plugin-vue-next'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import * as esbuild from "esbuild";
|
||||
import pluginVue from "esbuild-plugin-vue-next";
|
||||
import { sassPlugin } from "esbuild-sass-plugin";
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
const doWatch = process.env.WATCH === "true";
|
||||
const doMinify = process.env.MINIFY === "true";
|
||||
|
||||
const ctx = await esbuild.context(
|
||||
{
|
||||
entryPoints: [
|
||||
"server/ui-src/app.js",
|
||||
"server/ui-src/docs.js"
|
||||
],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ['import'],
|
||||
quietDeps: true,
|
||||
})
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
}
|
||||
)
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: "true",
|
||||
__VUE_PROD_DEVTOOLS__: "false",
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ["import"],
|
||||
quietDeps: true,
|
||||
}),
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
if (doWatch) {
|
||||
await ctx.watch()
|
||||
await ctx.watch();
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
ctx.dispose()
|
||||
await ctx.rebuild();
|
||||
ctx.dispose();
|
||||
}
|
||||
|
||||
34
eslint.config.js
Normal file
34
eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import neostandard, { resolveIgnoresFromGitignore } from "neostandard";
|
||||
import vue from "eslint-plugin-vue";
|
||||
|
||||
export default [
|
||||
/* Baseline JS rules, provided by Neostandard */
|
||||
...neostandard({
|
||||
/* Allows references to browser APIs like `document` */
|
||||
env: ["browser"],
|
||||
|
||||
/* We rely on .gitignore to avoid running against dist / dependency files */
|
||||
ignores: resolveIgnoresFromGitignore(),
|
||||
|
||||
/* Disables a range of style-related rules, as we use Prettier for that */
|
||||
noStyle: true,
|
||||
|
||||
/* Ensures we only lint JS and Vue files */
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
}),
|
||||
|
||||
/* Vue-specific rules */
|
||||
...vue.configs["flat/recommended"],
|
||||
|
||||
/* Prettier is responsible for formatting, so this disables any conflicting rules */
|
||||
eslintConfigPrettier,
|
||||
|
||||
/* Our custom rules */
|
||||
{
|
||||
rules: {
|
||||
/* We prefer arrow functions for tidiness and consistency */
|
||||
"prefer-arrow-callback": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
57
go.mod
57
go.mod
@@ -1,63 +1,72 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.23
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.2
|
||||
|
||||
// https://github.com/jaytaylor/html2text/issues/67
|
||||
replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/kovidgoyal/imaging v1.6.3
|
||||
github.com/jhillyerd/enmime/v2 v2.1.0
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/kovidgoyal/imaging v1.6.4
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/tg123/go-htpasswd v1.2.3
|
||||
github.com/vanng822/go-premailer v1.23.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/text v0.22.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.5
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.25.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/text v0.26.0
|
||||
golang.org/x/time v0.12.0
|
||||
modernc.org/sqlite v1.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/olekukonko/tablewriter v1.0.7 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.64.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/libc v1.61.12 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.28.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.66.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
172
go.sum
172
go.sum
@@ -1,34 +1,38 @@
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -41,19 +45,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
|
||||
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/jhillyerd/enmime/v2 v2.1.0 h1:c8Qwi5Xq5EdtMN6byQWoZ/8I2RMTo6OJ7Xay+s1oPO0=
|
||||
github.com/jhillyerd/enmime/v2 v2.1.0/go.mod h1:EJ74dcRbBcqHSP2TBu08XRoy6y3Yx0cevwb1YkGMEmQ=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
|
||||
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
@@ -68,6 +67,8 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
@@ -76,6 +77,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
|
||||
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -84,17 +93,14 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
@@ -102,59 +108,51 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tg123/go-htpasswd v1.2.3 h1:ALR6ZBIc2m9u70m+eAWUFt5p43ISbIvAvRFYzZPTOY8=
|
||||
github.com/tg123/go-htpasswd v1.2.3/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
|
||||
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
|
||||
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
|
||||
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.23.0 h1:vZp2wuz1jb4q/DurUV18VGjXWtTFYZHwTCw2EAWKO74=
|
||||
github.com/vanng822/go-premailer v1.23.0/go.mod h1:0+z0UJ6ZGQatzkWlaQNl50M7fLz5f6FcP8V2p0oie88=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/vanng822/go-premailer v1.25.0 h1:hGHKfroCXrCDTyGVR8o4HCON5/HWvc7C1uocS+VnaZs=
|
||||
github.com/vanng822/go-premailer v1.25.0/go.mod h1:8WJKIPZtegxqSOA8+eDFx7QNesKmMYfGEIodLTJqrtM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 h1:qNgPs5exUA+G0C96DrPwNrvLSj7GT/9D+3WMWUcUg34=
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -162,40 +160,32 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -205,47 +195,47 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.12 h1:Fsnh0A7XLXylYNwIOJmKux9PhnfrIvMaMnjuyJ1t/f4=
|
||||
modernc.org/libc v1.61.12/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.0.3 h1:y81b9r3asCh6Xtse6Nz85aYGB0cG3M3U6222yap1KWI=
|
||||
modernc.org/goabi0 v0.0.3/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.0 h1:eoFuDb1ozurUY5WSWlgvxHp0FuL+AncMwNjFqGYMJPQ=
|
||||
modernc.org/libc v1.66.0/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
258
install.sh
258
install.sh
@@ -1,98 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
GH_REPO="axllent/mailpit"
|
||||
TIMEOUT=90
|
||||
# This script will install the latest release of Mailpit.
|
||||
|
||||
set -e
|
||||
# Check dependencies is installed
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "Then $cmd command is required but not installed."
|
||||
echo "Please install $cmd and try again."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -ne "\nThere was an error trying to check what is the latest version of Mailpit.\nPlease try again later.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# detect the platform
|
||||
OS="$(uname)"
|
||||
case $OS in
|
||||
Linux)
|
||||
OS='linux'
|
||||
;;
|
||||
FreeBSD)
|
||||
OS='freebsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
NetBSD)
|
||||
OS='netbsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
OpenBSD)
|
||||
OS='openbsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
Darwin)
|
||||
OS='darwin'
|
||||
;;
|
||||
SunOS)
|
||||
OS='solaris'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
# Check if the OS is supported.
|
||||
OS=
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="Darwin" ;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
echo "OS not supported."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# detect the arch
|
||||
OS_type="$(uname -m)"
|
||||
case "$OS_type" in
|
||||
# Detect the architecture of the OS.
|
||||
OS_ARCH=
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64)
|
||||
OS_type='amd64'
|
||||
OS_ARCH="amd64"
|
||||
;;
|
||||
i?86 | x86)
|
||||
OS_type='386'
|
||||
OS_ARCH="386"
|
||||
;;
|
||||
aarch64 | arm64)
|
||||
OS_type='arm64'
|
||||
OS_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo 'OS type not supported'
|
||||
echo "OS architecture not supported."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
|
||||
GH_REPO="axllent/mailpit"
|
||||
INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin}"
|
||||
TIMEOUT=90
|
||||
# This is used to authenticate with the GitHub API. (Fix the public rate limiting issue)
|
||||
# Try the GITHUB_TOKEN environment variable is set globally.
|
||||
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
|
||||
|
||||
#create tmp directory and move to it with macOS compatibility fallback
|
||||
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
|
||||
cd "$tmp_dir"
|
||||
# Update the default values if the user has set.
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--install-path)
|
||||
shift
|
||||
case "$1" in
|
||||
*/*)
|
||||
# Remove trailing slashes from the path.
|
||||
INSTALL_PATH="$(echo "$1" | sed 's#/\+$##')"
|
||||
[ -z "$INSTALL_PATH" ] && INSTALL_PATH="/"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
--auth | --auth-token | --github-token | --token)
|
||||
shift
|
||||
case "$1" in
|
||||
gh*)
|
||||
GITHUB_API_TOKEN="$1"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
echo "Downloading Mailpit $VERSION"
|
||||
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
|
||||
# Description of the sort parameters for curl command.
|
||||
# -s: Silent mode.
|
||||
# -f: Fail silently on server errors.
|
||||
# -L: Follow redirects.
|
||||
# -m: Set maximum time allowed for the transfer.
|
||||
|
||||
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
|
||||
echo "Error downloading"
|
||||
exit 2
|
||||
}
|
||||
if [ -n "$GITHUB_API_TOKEN" ] && [ "${#GITHUB_API_TOKEN}" -gt 36 ]; then
|
||||
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT -H "Authorization: Bearer $GITHUB_API_TOKEN" https://api.github.com/repos/${GH_REPO}/releases/latest)"
|
||||
EXIT_CODE=$?
|
||||
else
|
||||
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT https://api.github.com/repos/${GH_REPO}/releases/latest)"
|
||||
EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
mkdir -p /usr/local/bin || exit 2
|
||||
cp mailpit /usr/local/bin/ || exit 2
|
||||
chmod 755 /usr/local/bin/mailpit || exit 2
|
||||
case "$OS" in
|
||||
'linux')
|
||||
chown root:root /usr/local/bin/mailpit || exit 2
|
||||
;;
|
||||
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
|
||||
chown root:wheel /usr/local/bin/mailpit || exit 2
|
||||
;;
|
||||
VERSION=""
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
# Extracts the latest version using jq, awk, or sed.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Use jq -n because the output is not a valid JSON in sh.
|
||||
VERSION=$(jq -n "$CURL_OUTPUT" | jq -r '.tag_name')
|
||||
elif command -v awk >/dev/null 2>&1; then
|
||||
VERSION=$(echo "$CURL_OUTPUT" | awk -F: '$1 ~ /tag_name/ {gsub(/[^v0-9\.]+/, "", $2) ;print $2; exit}')
|
||||
elif command -v sed >/dev/null 2>&1; then
|
||||
VERSION=$(echo "$CURL_OUTPUT" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
|
||||
else
|
||||
EXIT_CODE=3
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate the version.
|
||||
case "$VERSION" in
|
||||
v[0-9][0-9\.]*) ;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
echo "There was an error trying to check what is the latest version of Mailpit."
|
||||
echo "Please try again later."
|
||||
exit $EXIT_CODE
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
echo "Installed successfully to /usr/local/bin/mailpit"
|
||||
TEMP_DIR="$(mktemp -qd)"
|
||||
EXIT_CODE=$?
|
||||
# Ensure the temporary directory exists and is a directory.
|
||||
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
|
||||
echo "ERROR: Creating temporary directory."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
GH_REPO_BIN="mailpit-${OS}-${OS_ARCH}.tar.gz"
|
||||
if [ "$INSTALL_PATH" = "/" ]; then
|
||||
INSTALL_BIN_PATH="/mailpit"
|
||||
else
|
||||
INSTALL_BIN_PATH="${INSTALL_PATH}/mailpit"
|
||||
fi
|
||||
cd "$TEMP_DIR" || EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
# Download the latest release.
|
||||
#
|
||||
# Description of the sort parameters for curl command.
|
||||
# -s: Silent mode.
|
||||
# -f: Fail silently on server errors.
|
||||
# -L: Follow redirects.
|
||||
# -m: Set maximum time allowed for the transfer.
|
||||
# -o: Write output to a file instead of stdout.
|
||||
curl -sfL -m $TIMEOUT -o "${GH_REPO_BIN}" "https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
|
||||
EXIT_CODE=$?
|
||||
|
||||
# The following conditions check each step of the installation.
|
||||
# If there is an error in any of the steps, an error message is printed.
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
if ! [ -f "${GH_REPO_BIN}" ]; then
|
||||
EXIT_CODE=1
|
||||
echo "ERROR: Downloading latest release."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
tar zxf "$GH_REPO_BIN"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Extracting \"${GH_REPO_BIN}\"."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ] && [ ! -d "$INSTALL_PATH" ]; then
|
||||
mkdir -p "${INSTALL_PATH}"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Creating \"${INSTALL_PATH}\" directory."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
cp mailpit "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Copying mailpit to \"${INSTALL_PATH}\" directory."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
chmod 755 "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Setting permissions for \"$INSTALL_BIN_PATH\" binary."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set the owner and group to root:root if the script is run as root.
|
||||
if [ $EXIT_CODE -eq 0 ] && [ "$(id -u)" -eq "0" ]; then
|
||||
OWNER="root"
|
||||
GROUP="root"
|
||||
# Set the OWNER, GROUP variable when the OS not use the default root:root.
|
||||
case "$OS" in
|
||||
darwin) GROUP="wheel" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
chown "${OWNER}:${GROUP}" "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Setting ownership for \"$INSTALL_BIN_PATH\" binary."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Changing to temporary directory."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
# Cleanup the temporary directory.
|
||||
rm -rf "$TEMP_DIR"
|
||||
# Check the EXIT_CODE variable, and print the success or error message.
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "There was an error installing Mailpit."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
echo "Installed successfully to \"$INSTALL_BIN_PATH\"."
|
||||
exit 0
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
var (
|
||||
// UICredentials passwords
|
||||
UICredentials *htpasswd.File
|
||||
// SendAPICredentials passwords
|
||||
SendAPICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
// POP3Credentials passwords
|
||||
@@ -36,6 +38,25 @@ func SetUIAuth(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSendAPIAuth will set Send API credentials
|
||||
func SetSendAPIAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSMTPAuth will set SMTP credentials
|
||||
func SetSMTPAuth(s string) error {
|
||||
var err error
|
||||
|
||||
163
internal/dump/dump.go
Normal file
163
internal/dump/dump.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package dump is used to export all messages from mailpit into a directory
|
||||
package dump
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
)
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
outDir string
|
||||
|
||||
// Base URL of mailpit instance
|
||||
base string
|
||||
|
||||
// URL is the base URL of a remove Mailpit instance
|
||||
URL string
|
||||
|
||||
summary = []storage.MessageSummary{}
|
||||
)
|
||||
|
||||
// Sync will sync all messages from the specified database or API to the specified output directory
|
||||
func Sync(d string) error {
|
||||
|
||||
outDir = path.Clean(d)
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
return errors.New("Invalid URL")
|
||||
}
|
||||
|
||||
base = strings.TrimRight(URL, "/") + "/"
|
||||
}
|
||||
|
||||
if base == "" && config.Database == "" {
|
||||
return errors.New("No database or API URL specified")
|
||||
}
|
||||
|
||||
if !tools.IsDir(outDir) {
|
||||
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := loadIDs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := saveMessages(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadIDs will load all message IDs from the specified database or API
|
||||
func loadIDs() error {
|
||||
if base != "" {
|
||||
// remote
|
||||
logger.Log().Debugf("Fetching messages summary from %s", base)
|
||||
res, err := http.Get(base + "api/v1/messages?limit=0")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary = data.Messages
|
||||
|
||||
} else {
|
||||
// make sure the database isn't pruned while open
|
||||
config.MaxMessages = 0
|
||||
|
||||
var err error
|
||||
// local database
|
||||
if err = storage.InitDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
|
||||
|
||||
summary, err = storage.List(0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
return errors.New("No messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveMessages() error {
|
||||
for _, m := range summary {
|
||||
out := path.Join(outDir, m.ID+".eml")
|
||||
|
||||
// skip if message exists
|
||||
if tools.IsFile(out) {
|
||||
continue
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
if base != "" {
|
||||
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
b, err = io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
b, err = storage.GetMessageRaw(m.ID)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
|
||||
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.Chtimes(out, m.Created, m.Created)
|
||||
|
||||
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2024-11-29 15:25:23 +0000",
|
||||
"last_update_date":"2025-06-12 18:27:28 +0000",
|
||||
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
|
||||
"data":[
|
||||
{
|
||||
@@ -190,9 +190,9 @@
|
||||
"last_test_date":"2019-08-20",
|
||||
"test_url":"https://www.caniemail.com/tests/css-media.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"n","12":"y","13":"y","15":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"n","12":"y","13":"y","15":"y","18.3.2":"a #2"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y","2025-04":"n"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-08":"y #1","2021-03":"y","2024-04":"y"},"ios":{"2019-08":"n","2024-04":"y"},"android":{"2019-08":"y","2024-04":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"5.0.10.2":"n","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"thunderbird":{"macos":{"60.3":"y","78.5":"n"}},"yahoo":{"desktop-webmail":{"2019-08":"n"},"ios":{"2019-08":"n"},"android":{"2019-08":"n"}},"aol":{"desktop-webmail":{"2019-02":"n"},"ios":{"2019-02":"n"},"android":{"2019-02":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. The first rule inside a media query is not prefixed."}
|
||||
"notes_by_num":{"1":"Buggy. The first rule inside a media query is not prefixed.","2":"Partial. `orientation:portrait` is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -521,7 +521,7 @@
|
||||
"description":"Support for border radius logical properties",
|
||||
"url":"https://www.caniemail.com/features/css-border-radius-logical/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius",
|
||||
"last_test_date":"2022-08-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
|
||||
@@ -910,7 +910,7 @@
|
||||
"last_test_date":"2022-08-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-font-kerning.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list",
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"128.9.0":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"last_test_date":"2019-09-27",
|
||||
"test_url":"https://www.caniemail.com/tests/css-width-height.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"y #1"},"ios":{"2019-09":"y #1"},"android":{"2019-09":"y #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2023-12":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"yahoo":{"desktop-webmail":{"2019-09":"n #1"},"ios":{"2019-09":"n #1"},"android":{"2019-09":"n #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
|
||||
},
|
||||
@@ -1177,8 +1177,8 @@
|
||||
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
|
||||
"url":"https://www.caniemail.com/features/css-inset/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"tags":["i18n"],
|
||||
"keywords":"inset-block, inset-inline, inset-inline-start, inset-inline-end, inset-block-start, inset-block-end",
|
||||
"last_test_date":"2024-05-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-inset.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
|
||||
@@ -1230,9 +1230,9 @@
|
||||
"last_test_date":"2021-05-16",
|
||||
"test_url":"https://www.caniemail.com/tests/css-positioning.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list",
|
||||
"stats":{"apple-mail":{"macos":{"14":"y"},"ios":{"14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"14":"y"},"ios":{"14.5":"y"}},"gmail":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"},"mobile-webmail":{"2021-05":"n"}},"orange":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-05":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2021-05":"y","2023-12":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"samsung-email":{"android":{"6.0":"y","6.2.01.1":"a #2"}},"sfr":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"thunderbird":{"macos":{"78.10":"y"}},"aol":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"yahoo":{"desktop-webmail":{"2021-05":"n"},"ios":{"2021-05":"n"},"android":{"2021-05":"n"}},"protonmail":{"desktop-webmail":{"2021-05":"y"},"ios":{"2021-05":"y"},"android":{"2021-05":"y"}},"hey":{"desktop-webmail":{"2021-05":"y"}},"mail-ru":{"desktop-webmail":{"2021-05":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `left` and `top` are not supported."}
|
||||
"notes_by_num":{"1":"Partial. `left` and `top` are not supported.","2":"Partial. Percentages values are not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1278,9 +1278,9 @@
|
||||
"last_test_date":"2021-12-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #3"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2023-12":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y","6.1.90.16":"a #2"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts."}
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradient\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Partial. Not supported with Hotmail/Outlook accounts.","3":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1374,7 +1374,7 @@
|
||||
"last_test_date":"2022-07-12",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
|
||||
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1395,22 +1395,6 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-margin-inline",
|
||||
"title":"margin-inline & margin-block",
|
||||
"description":"Support for the `margin-inline` and `margin-block` shorthand properties.",
|
||||
"url":"https://www.caniemail.com/features/css-margin-inline/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"margin-inline, margin-block",
|
||||
"last_test_date":"2022-07-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin-logical-properties.html",
|
||||
"test_results_url":"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik",
|
||||
"stats":{"apple-mail":{"macos":{"10.12.6":"n","10.13.6":"n","10.15.7":"n","12.4":"y"},"ios":{"11.4":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"y","2021-03":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n"}},"sfr":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"hey":{"desktop-webmail":{"2022-07":"y"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"y"}},"laposte":{"desktop-webmail":{"2022-07":"y"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-margin",
|
||||
"title":"margin",
|
||||
@@ -1449,7 +1433,7 @@
|
||||
"description":"",
|
||||
"url":"https://www.caniemail.com/features/css-max-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"max, block, size",
|
||||
"last_test_date":"2022-09-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-block-size.html",
|
||||
@@ -1481,7 +1465,7 @@
|
||||
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-max-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"max, inline, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
|
||||
@@ -1513,7 +1497,7 @@
|
||||
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-min-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"min, block, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
|
||||
@@ -1545,7 +1529,7 @@
|
||||
"description":"",
|
||||
"url":"https://www.caniemail.com/features/css-min-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"min, inline, size",
|
||||
"last_test_date":"2022-08-30",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
|
||||
@@ -1614,7 +1598,7 @@
|
||||
"last_test_date":"2023-08-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-nesting.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list",
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n","137.0b3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `E { F {}}` doesn’t work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."}
|
||||
},
|
||||
@@ -1726,9 +1710,9 @@
|
||||
"last_test_date":"2022-08-03",
|
||||
"test_url":"https://www.caniemail.com/tests/css-overflow-wrap.html",
|
||||
"test_results_url":"https://testi.at/proj/zxOsWrYsJqztvWC7JYF8xrUgn",
|
||||
"stats":{"apple-mail":{"macos":{"16":"n","17":"n","18":"n","19":"n","20":"n","21":"n"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"18.3.2":"a #1"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
"notes_by_num":{"1":"Buggy. Requires `word-break:normal` to reset Apple Mail default style (See [issue#394](https://github.com/hteumeuleu/caniemail/issues/394).)"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1738,7 +1722,7 @@
|
||||
"url":"https://www.caniemail.com/features/css-overflow/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"keywords":"overflow-block, overflow-inline",
|
||||
"last_test_date":"2024-10-02",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://testi.at/proj/p4rru3ez069p15p6ij",
|
||||
@@ -1838,9 +1822,9 @@
|
||||
"last_test_date":"2019-10-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"n"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n","16":"a #2"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"n"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors.","2":"Partial. Supported with mouse clicks. Not supported with keyboard input."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1902,7 +1886,7 @@
|
||||
"last_test_date":"2019-10-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"n"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"n","16":"y"},"ios":{"13.1":"n"}},"gmail":{"desktop-webmail":{"2019-10":"n"},"ios":{"2019-10":"n"},"android":{"2019-10":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-10":"n"},"macos":{"2019-02":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2024-01":"a #1"},"ios":{"2019-10":"n"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.8":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"n"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"web-de":{"desktop-webmail":{"2022-07":"y"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"n"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Only supported on type selectors."}
|
||||
},
|
||||
@@ -2254,9 +2238,9 @@
|
||||
"last_test_date":"2021-12-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-gradients.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y","2025-04":"a #2"},"mobile-webmail":{"2021-12":"y"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2021-12":"n"},"macos":{"16.57":"y","16.80":"n"},"outlook-com":{"2021-12":"n","2024-01":"n"},"ios":{"2.51.1":"y","4.2148.2":"n"},"android":{"4.2147.4":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"thunderbird":{"macos":{"91.4.1":"y"}},"aol":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"y"}},"fastmail":{"desktop-webmail":{"2021-12":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"t-online-de":{"desktop-webmail":{"2021-12":"y"}},"free-fr":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill)."}
|
||||
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).","2":"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))"}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -2638,7 +2622,7 @@
|
||||
"last_test_date":"2023-12-06",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-decoration-line.html",
|
||||
"test_results_url":"https://testi.at/proj/kg6y1a4eiynkf8z7t4",
|
||||
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"6":"n","7":"n","8":"y","9":"y","10":"y","11":"y","12":"y","13":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"y","16.80":"y"},"outlook-com":{"2023-12":"y","2024-01":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"n"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"n"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -2777,7 +2761,7 @@
|
||||
"description":"Sets the orientation of the text characters in vertical mode.",
|
||||
"url":"https://www.caniemail.com/features/css-text-orientation/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"tags":["i18n"],
|
||||
"keywords":"vertical orientation",
|
||||
"last_test_date":"2023-12-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-orientation.html",
|
||||
@@ -3385,7 +3369,7 @@
|
||||
"description":"Represents an abbreviation or acronym.",
|
||||
"url":"https://www.caniemail.com/features/html-abbr/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-09-13",
|
||||
"test_url":"https://www.caniemail.com/tests/html-abbr.html",
|
||||
@@ -3566,7 +3550,7 @@
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-background.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #2","2010":"a #2","2013":"a #2","2016":"a #2","2019":"a #2"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0.04.6":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5.0":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"t-online-de":{"desktop-webmail":{"2021-11":"y"}},"free-fr":{"desktop-webmail":{"2021-11":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Only supported on the `<body>` element."}
|
||||
},
|
||||
@@ -3630,7 +3614,7 @@
|
||||
"last_test_date":"2021-11-30",
|
||||
"test_url":"https://www.caniemail.com/tests/html-body.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/M1w9fKYqtXsrlJ2mlElp9b2RoSd7lDcWwftkDazPgy4hm/list",
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"},"mobile-webmail":{"2021-11":"a #1"}},"orange":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"a #1"},"android":{"2021-11":"a #1"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2021-11":"y"},"macos":{"16.56":"y","16.80":"n"},"outlook-com":{"2021-11":"a #1","2024-01":"a #1"},"ios":{"2021-11":"n","2025-04":"a #1"},"android":{"2021-11":"n","2025-04":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"thunderbird":{"macos":{"78.14":"y"}},"aol":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"yahoo":{"desktop-webmail":{"2021-11":"a #1"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"protonmail":{"desktop-webmail":{"2021-11":"n"},"ios":{"2021-11":"n"},"android":{"2021-11":"n"}},"hey":{"desktop-webmail":{"2021-11":"a #1"}},"mail-ru":{"desktop-webmail":{"2021-11":"n"}},"fastmail":{"desktop-webmail":{"2021-11":"n"}},"laposte":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Replaced by a `<div>` with supported attributes."}
|
||||
},
|
||||
@@ -3705,7 +3689,7 @@
|
||||
"description":"A short fragment of computer code.",
|
||||
"url":"https://www.caniemail.com/features/html-code/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-04-25",
|
||||
"test_url":"https://www.caniemail.com/tests/html-code.html",
|
||||
@@ -3785,7 +3769,7 @@
|
||||
"description":"Indicates the directionality of the element's text.",
|
||||
"url":"https://www.caniemail.com/features/html-dir/",
|
||||
"category":"html",
|
||||
"tags":["i18n"],
|
||||
"tags":["i18n","accessibility"],
|
||||
"keywords":"direction, ltr, rtl",
|
||||
"last_test_date":"2021-11-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-direction.html",
|
||||
@@ -3897,12 +3881,12 @@
|
||||
"description":"HTML horizontal rule",
|
||||
"url":"https://www.caniemail.com/features/html-hr/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-09-08",
|
||||
"test_url":"https://www.caniemail.com/tests/html-hr.html",
|
||||
"test_results_url":"https://testi.at/proj/e6ndurbxtpz9hz95hp",
|
||||
"stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2023-09":"y"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"thunderbird":{"macos":{"102.10.1":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"y"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"u"}},"gmx":{"desktop-webmail":{"2023-09":"u"}},"web-de":{"desktop-webmail":{"2023-09":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2023-09":"y"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"thunderbird":{"macos":{"102.10.1":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"y"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"u"}},"gmx":{"desktop-webmail":{"2023-09":"u"}},"web-de":{"desktop-webmail":{"2023-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -3929,7 +3913,7 @@
|
||||
"description":"Displays an image into the document",
|
||||
"url":"https://www.caniemail.com/features/html-img/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"tags":["performance","accessibility"],
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-12-16",
|
||||
"test_url":"https://www.caniemail.com/tests/html-img.html",
|
||||
@@ -4461,8 +4445,8 @@
|
||||
"keywords":null,
|
||||
"last_test_date":"2023-07-27",
|
||||
"test_url":"https://www.caniemail.com/tests/html-style.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/od5IYQtx8yIbIUbeRyQXnP0yzFKEm2E9CKa3FU4BcEXFv/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/CAMb612bxbVwRWPhM4wZKNhhdcdkNxj0Rj6dtRRw6LQUO/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y","16.80":"y"},"outlook-com":{"2019-06":"y","2023-01":"y","2024-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y","2025-04":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}},"wp-pl":{"desktop-webmail":{"2023-12":"y"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{"1":"Partial. Not supported inside the `<body>`.","2":"Partial. Not supported with non Google accounts.","3":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","4":"Buggy. `<style>` elements need to be declared before their rules are used.","5":"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)"}
|
||||
},
|
||||
|
||||
@@ -38,167 +38,178 @@ var htmlTests = map[string]string{
|
||||
|
||||
// Image tests using regex to match against img[src]
|
||||
var imageRegexpTests = map[string]*regexp.Regexp{
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`),
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`),
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`),
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`),
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`),
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`),
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`),
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`),
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`),
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`),
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`),
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`),
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`),
|
||||
}
|
||||
|
||||
var cssInlineTests = map[string]string{
|
||||
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
|
||||
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
|
||||
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
|
||||
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
|
||||
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
|
||||
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
|
||||
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
|
||||
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
|
||||
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
|
||||
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
|
||||
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
|
||||
"css-background": "[style*=\"background:\"], [background]", // 57.407406
|
||||
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
|
||||
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
|
||||
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
|
||||
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
|
||||
"css-border": "[style*=\"border:\"], [border]", // 86.95652
|
||||
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
|
||||
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
|
||||
"css-caption-side": "[style*=\"caption-side:\"]", // 84
|
||||
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
|
||||
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
|
||||
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
|
||||
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
|
||||
"css-direction": "[style*=\"direction:\"]", // 97.77778
|
||||
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
|
||||
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
|
||||
"css-display-none": "[style*=\"display:none\"]", // 84.78261
|
||||
"css-display": "[style*=\"display:\"]", // 55.555553
|
||||
"css-filter": "[style*=\"filter:\"]", // 50
|
||||
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
|
||||
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
|
||||
"css-float": "[style*=\"float:\"]", // 85.10638
|
||||
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
|
||||
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
|
||||
"css-font": "[style*=\"font:\"]", // 95.833336
|
||||
"css-gap": "[style*=\"gap:\"]", // 40
|
||||
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
|
||||
"css-height": "[style*=\"height:\"], [height]", // 77.08333
|
||||
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
|
||||
"css-important": "[style*=\"!important\"]", // 43.478264
|
||||
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
|
||||
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
|
||||
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
|
||||
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
|
||||
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
|
||||
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
|
||||
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
|
||||
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
|
||||
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
|
||||
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
|
||||
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
|
||||
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
|
||||
"css-margin": "[style*=\"margin:\"]", // 71.42857
|
||||
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
|
||||
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
|
||||
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
|
||||
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
|
||||
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
|
||||
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
|
||||
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
|
||||
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
|
||||
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
|
||||
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
|
||||
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
|
||||
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
|
||||
"css-outline": "[style*=\"outline:\"]", // 80.85106
|
||||
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
|
||||
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
|
||||
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
|
||||
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
|
||||
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
|
||||
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
|
||||
"css-position": "[style*=\"position:\"]", // 19.56522
|
||||
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
|
||||
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
|
||||
"css-rgba": "[style*=\"rgba(\"]", // 56
|
||||
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
|
||||
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
|
||||
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
|
||||
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
|
||||
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
|
||||
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
|
||||
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
|
||||
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
|
||||
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
|
||||
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
|
||||
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
|
||||
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
|
||||
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
|
||||
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
|
||||
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
|
||||
"css-transform": "[style*=\"transform:\"]", // 50
|
||||
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
|
||||
"css-variables": "[style*=\"variables:\"]", // 46.551727
|
||||
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
|
||||
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
|
||||
"css-width": "[style*=\"width:\"], [width]", // 87.5
|
||||
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
|
||||
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
|
||||
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
|
||||
// inline attribute <match>=""
|
||||
var styleInlineAttributes = map[string]string{
|
||||
"css-background-color": "[bgcolor]",
|
||||
"css-background": "[background]",
|
||||
"css-border": "[border]",
|
||||
"css-height": "[height]",
|
||||
"css-padding": "[padding]",
|
||||
"css-width": "[width]",
|
||||
}
|
||||
|
||||
// inline style="<match>"
|
||||
var cssInlineRegexTests = map[string]*regexp.Regexp{
|
||||
"css-accent-color": regexp.MustCompile(`(?i)(^|\s|;)accent-color(\s+)?:`),
|
||||
"css-align-items": regexp.MustCompile(`(?i)(^|\s|;)align-items(\s+)?:`),
|
||||
"css-aspect-ratio": regexp.MustCompile(`(?i)(^|\s|;)aspect-ratio(\s+)?:`),
|
||||
"css-background-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)background-blend-mode(\s+)?:`),
|
||||
"css-background-clip": regexp.MustCompile(`(?i)(^|\s|;)background-clip(\s+)?:`),
|
||||
"css-background-color": regexp.MustCompile(`(?i)(^|\s|;)background-color(\s+)?:`),
|
||||
"css-background-image": regexp.MustCompile(`(?i)(^|\s|;)background-image(\s+)?:`),
|
||||
"css-background-origin": regexp.MustCompile(`(?i)(^|\s|;)background-origin(\s+)?:`),
|
||||
"css-background-position": regexp.MustCompile(`(?i)(^|\s|;)background-position(\s+)?:`),
|
||||
"css-background-repeat": regexp.MustCompile(`(?i)(^|\s|;)background-repeat(\s+)?:`),
|
||||
"css-background-size": regexp.MustCompile(`(?i)(^|\s|;)background-size(\s+)?:`),
|
||||
"css-background": regexp.MustCompile(`(?i)(^|\s|;)background(\s+)?:`),
|
||||
"css-block-inline-size": regexp.MustCompile(`(?i)(^|\s|;)block-inline-size(\s+)?:`),
|
||||
"css-border-image": regexp.MustCompile(`(?i)(^|\s|;)border-image(\s+)?:`),
|
||||
"css-border-inline-block-individual": regexp.MustCompile(`(?i)(^|\s|;)border-inline(\s+)?:`),
|
||||
"css-border-radius": regexp.MustCompile(`(?i)(^|\s|;)border-radius(\s+)?:`),
|
||||
"css-border": regexp.MustCompile(`(?i)(^|\s|;)border(\s+)?:`),
|
||||
"css-box-shadow": regexp.MustCompile(`(?i)(^|\s|;)box-shadow(\s+)?:`),
|
||||
"css-box-sizing": regexp.MustCompile(`(?i)(^|\s|;)box-sizing(\s+)?:`),
|
||||
"css-caption-side": regexp.MustCompile(`(?i)(^|\s|;)caption-side(\s+)?:`),
|
||||
"css-clip-path": regexp.MustCompile(`(?i)(^|\s|;)clip-path(\s+)?:`),
|
||||
"css-column-count": regexp.MustCompile(`(?i)(^|\s|;)column-count(\s+)?:`),
|
||||
"css-column-layout-properties": regexp.MustCompile(`(?i)(^|\s|;)column-layout-properties(\s+)?:`),
|
||||
"css-conic-gradient": regexp.MustCompile(`(?i)(^|\s|;)conic-gradient(\s+)?:`),
|
||||
"css-direction": regexp.MustCompile(`(?i)(^|\s|;)direction(\s+)?:`),
|
||||
"css-display-flex": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:(\s+)?flex($|\s|;)`),
|
||||
"css-display-grid": regexp.MustCompile(`(?i)(^|\s|;)display:grid`),
|
||||
"css-display-none": regexp.MustCompile(`(?i)(^|\s|;)display:none`),
|
||||
"css-display": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:`),
|
||||
"css-filter": regexp.MustCompile(`(?i)(^|\s|;)filter(\s+)?:`),
|
||||
"css-flex-direction": regexp.MustCompile(`(?i)(^|\s|;)flex-direction(\s+)?:`),
|
||||
"css-flex-wrap": regexp.MustCompile(`(?i)(^|\s|;)flex-wrap(\s+)?:`),
|
||||
"css-float": regexp.MustCompile(`(?i)(^|\s|;)float(\s+)?:`),
|
||||
"css-font-kerning": regexp.MustCompile(`(?i)(^|\s|;)font-kerning(\s+)?:`),
|
||||
"css-font-weight": regexp.MustCompile(`(?i)(^|\s|;)font-weight(\s+)?:`),
|
||||
"css-font": regexp.MustCompile(`(?i)(^|\s|;)font(\s+)?:`),
|
||||
"css-gap": regexp.MustCompile(`(?i)(^|\s|;)gap(\s+)?:`),
|
||||
"css-grid-template": regexp.MustCompile(`(?i)(^|\s|;)grid-template(\s+)?:`),
|
||||
"css-height": regexp.MustCompile(`(?i)(^|\s|;)height(\s+)?:`),
|
||||
"css-hyphens": regexp.MustCompile(`(?i)(^|\s|;)hyphens(\s+)?:`),
|
||||
"css-important": regexp.MustCompile(`(?i)!important($|\s|;)`),
|
||||
"css-inline-size": regexp.MustCompile(`(?i)(^|\s|;)inline-size(\s+)?:`),
|
||||
"css-intrinsic-size": regexp.MustCompile(`(?i)(^|\s|;)intrinsic-size(\s+)?:`),
|
||||
"css-justify-content": regexp.MustCompile(`(?i)(^|\s|;)justify-content(\s+)?:`),
|
||||
"css-letter-spacing": regexp.MustCompile(`(?i)(^|\s|;)letter-spacing(\s+)?:`),
|
||||
"css-line-height": regexp.MustCompile(`(?i)(^|\s|;)line-height(\s+)?:`),
|
||||
"css-list-style-image": regexp.MustCompile(`(?i)(^|\s|;)list-style-image(\s+)?:`),
|
||||
"css-list-style-position": regexp.MustCompile(`(?i)(^|\s|;)list-style-position(\s+)?:`),
|
||||
"css-list-style": regexp.MustCompile(`(?i)(^|\s|;)list-style(\s+)?:`),
|
||||
"css-margin-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-block-(start|end)(\s+)?:`),
|
||||
"css-margin-inline-block": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-block(\s+)?:`),
|
||||
"css-margin-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-(start|end)(\s+)?:`),
|
||||
"css-margin-inline": regexp.MustCompile(`(?i)(^|\s|;)margin-inline(\s+)?:`),
|
||||
"css-margin": regexp.MustCompile(`(?i)(^|\s|;)margin(\s+)?:`),
|
||||
"css-max-block-size": regexp.MustCompile(`(?i)(^|\s|;)max-block-size(\s+)?:`),
|
||||
"css-max-height": regexp.MustCompile(`(?i)(^|\s|;)max-height(\s+)?:`),
|
||||
"css-max-width": regexp.MustCompile(`(?i)(^|\s|;)max-width(\s+)?:`),
|
||||
"css-min-height": regexp.MustCompile(`(?i)(^|\s|;)min-height(\s+)?:`),
|
||||
"css-min-inline-size": regexp.MustCompile(`(?i)(^|\s|;)min-inline-size(\s+)?:`),
|
||||
"css-min-width": regexp.MustCompile(`(?i)(^|\s|;)min-width(\s+)?:`),
|
||||
"css-mix-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)mix-blend-mode(\s+)?:`),
|
||||
"css-modern-color": regexp.MustCompile(`(?i)(^|\s|;)modern-color(\s+)?:`),
|
||||
"css-object-fit": regexp.MustCompile(`(?i)(^|\s|;)object-fit(\s+)?:`),
|
||||
"css-object-position": regexp.MustCompile(`(?i)(^|\s|;)object-position(\s+)?:`),
|
||||
"css-opacity": regexp.MustCompile(`(?i)(^|\s|;)opacity(\s+)?:`),
|
||||
"css-outline-offset": regexp.MustCompile(`(?i)(^|\s|;)outline-offset(\s+)?:`),
|
||||
"css-outline": regexp.MustCompile(`(?i)(^|\s|;)outline(\s+)?:`),
|
||||
"css-overflow-wrap": regexp.MustCompile(`(?i)(^|\s|;)overflow-wrap(\s+)?:`),
|
||||
"css-overflow": regexp.MustCompile(`(?i)(^|\s|;)overflow(\s+)?:`),
|
||||
"css-padding-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-block-(start|end)(\s+)?:`),
|
||||
"css-padding-inline-block": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-block(\s+)?:`),
|
||||
"css-padding-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-(start|end)(\s+)?:`),
|
||||
"css-padding": regexp.MustCompile(`(?i)(^|\s|;)padding(\s+)?:`),
|
||||
"css-position": regexp.MustCompile(`(?i)(^|\s|;)position(\s+)?:`),
|
||||
"css-radial-gradient": regexp.MustCompile(`(?i)(^|\s|;)radial-gradient(\s+)?:`),
|
||||
"css-rgb": regexp.MustCompile(`(?i)(\s|:)rgb\(`),
|
||||
"css-rgba": regexp.MustCompile(`(?i)(\s|:)rgba\(`),
|
||||
"css-scroll-snap": regexp.MustCompile(`(?i)(^|\s|;)roll-snap(\s+)?:`),
|
||||
"css-tab-size": regexp.MustCompile(`(?i)(^|\s|;)tab-size(\s+)?:`),
|
||||
"css-table-layout": regexp.MustCompile(`(?i)(^|\s|;)table-layout(\s+)?:`),
|
||||
"css-text-align-last": regexp.MustCompile(`(?i)(^|\s|;)text-align-last(\s+)?:`),
|
||||
"css-text-align": regexp.MustCompile(`(?i)(^|\s|;)text-align(\s+)?:`),
|
||||
"css-text-decoration-color": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-color(\s+)?:`),
|
||||
"css-text-decoration-thickness": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-thickness(\s+)?:`),
|
||||
"css-text-decoration": regexp.MustCompile(`(?i)(^|\s|;)text-decoration(\s+)?:`),
|
||||
"css-text-emphasis-position": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis-position(\s+)?:`),
|
||||
"css-text-emphasis": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis(\s+)?:`),
|
||||
"css-text-indent": regexp.MustCompile(`(?i)(^|\s|;)text-indent(\s+)?:`),
|
||||
"css-text-overflow": regexp.MustCompile(`(?i)(^|\s|;)text-overflow(\s+)?:`),
|
||||
"css-text-shadow": regexp.MustCompile(`(?i)(^|\s|;)text-shadow(\s+)?:`),
|
||||
"css-text-transform": regexp.MustCompile(`(?i)(^|\s|;)text-transform(\s+)?:`),
|
||||
"css-text-underline-offset": regexp.MustCompile(`(?i)(^|\s|;)text-underline-offset(\s+)?:`),
|
||||
"css-transform": regexp.MustCompile(`(?i)(^|\s|;)transform(\s+)?:`),
|
||||
"css-unit-calc": regexp.MustCompile(`(?i)(\s|:)calc\(`),
|
||||
"css-variables": regexp.MustCompile(`(?i)(^|\s|;)variables(\s+)?:`),
|
||||
"css-visibility": regexp.MustCompile(`(?i)(^|\s|;)visibility(\s+)?:`),
|
||||
"css-white-space": regexp.MustCompile(`(?i)(^|\s|;)white-space(\s+)?:`),
|
||||
"css-width": regexp.MustCompile(`(?i)(^|\s|;)width(\s+)?:`),
|
||||
"css-word-break": regexp.MustCompile(`(?i)(^|\s|;)word-break(\s+)?:`),
|
||||
"css-writing-mode": regexp.MustCompile(`(?i)(^|\s|;)writing-mode(\s+)?:`),
|
||||
"css-z-index": regexp.MustCompile(`(?i)(^|\s|;)z-index(\s+)?:`),
|
||||
}
|
||||
|
||||
// some CSS tests using regex for things that can't be merged inline
|
||||
var cssRegexpTests = map[string]*regexp.Regexp{
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`),
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`),
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`),
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`),
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`),
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`),
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`),
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`),
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`),
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`),
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`),
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`),
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`),
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`),
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`),
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`),
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`),
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`),
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`),
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`),
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`),
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`),
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`),
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`),
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`),
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`),
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`),
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`),
|
||||
}
|
||||
|
||||
// some CSS tests using regex for units
|
||||
var cssRegexpUnitTests = map[string]*regexp.Regexp{
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`),
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`),
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`),
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`),
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`),
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`),
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`),
|
||||
}
|
||||
|
||||
@@ -42,17 +42,15 @@ func runCSSTests(html string) ([]Warning, int, error) {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
for key, test := range cssInlineTests {
|
||||
totalTests++
|
||||
found := len(doc.Find(test).Nodes)
|
||||
if found > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
inlineStyleResults := testInlineStyles(doc)
|
||||
totalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)
|
||||
for key, count := range inlineStyleResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err == nil {
|
||||
result.Score.Found = count
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
@@ -215,3 +213,39 @@ func isURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
// Test the HTML for inline CSS styles and styling attributes
|
||||
func testInlineStyles(doc *goquery.Document) map[string]int {
|
||||
matches := make(map[string]int)
|
||||
|
||||
// find all elements containing a style attribute
|
||||
styles := doc.Find("[style]").Nodes
|
||||
for _, s := range styles {
|
||||
style, err := tools.GetHTMLAttributeVal(s, "style")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for id, test := range cssInlineRegexTests {
|
||||
if test.MatchString(style) {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = 0
|
||||
}
|
||||
matches[id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find all elements containing styleInlineAttributes
|
||||
for id, test := range styleInlineAttributes {
|
||||
a := doc.Find(test).Nodes
|
||||
if len(a) > 0 {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = 0
|
||||
}
|
||||
matches[id]++
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
81
internal/htmlcheck/inline_test.go
Normal file
81
internal/htmlcheck/inline_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func TestInlineStyleDetection(t *testing.T) {
|
||||
/// tests should contain the HTML test, and expected test results in alphabetical order
|
||||
tests := map[string]string{}
|
||||
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="color: green; transform:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="color:green; transform :rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="transform:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="TRANSFORM:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="ignore-transform: something">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="text-transform: uppercase">Heading</h1>`] = "css-text-transform"
|
||||
tests[`<h1 style="text-transform: uppercase; text-transform: uppercase">Heading</h1>`] = "css-text-transform"
|
||||
tests[`<h1 style="test-transform: uppercase">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="padding-inline-start: 5rem">Heading</h1>`] = "css-padding-inline-start-end"
|
||||
tests[`<h1 style="margin-inline-end: 5rem">Heading</h1>`] = "css-margin-inline-start-end"
|
||||
tests[`<h1 style="margin-inline-middle: 5rem">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="color:green!important">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color: green !important">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color: green!important;">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color:green!important-stuff;">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="background-image:url('img.jpg')">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style="background-image:url('img.jpg'); color: green">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style=" color:green; background-image:url('img.jpg');">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style="display : flex ;">Heading</h1>`] = "css-display,css-display-flex"
|
||||
tests[`<h1 style="DISPLAY:FLEX;">Heading</h1>`] = "css-display,css-display-flex"
|
||||
tests[`<h1 style="display: flexing;">Heading</h1>`] = "css-display" // should not match css-display-flex rule
|
||||
tests[`<h1 style="line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)">Heading</h1>`] = "css-line-height,css-opacity,css-unit-calc,css-width"
|
||||
tests[`<h1 style="color: rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgba(255,255,255, 0);">Heading</h1>`] = "css-rgba"
|
||||
tests[`<h1 style="border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);">Heading</h1>`] = "css-border,css-rgb,css-rgba"
|
||||
tests[`<h1 border="2">Heading</h1>`] = "css-border"
|
||||
tests[`<h1 border="2" background="green">Heading</h1>`] = "css-background,css-border"
|
||||
tests[`<h1 BORDER="2" BACKGROUND="GREEN">Heading</h1>`] = "css-background,css-border"
|
||||
tests[`<h1 border-something="2" background-something="green">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 border="2" style="border: solid green 1px!important">Heading</h1>`] = "css-border,css-important"
|
||||
|
||||
for html, expected := range tests {
|
||||
reader := strings.NewReader(html)
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
results := testInlineStyles(doc)
|
||||
|
||||
matches := []string{}
|
||||
uniqMap := make(map[string]bool)
|
||||
for key := range results {
|
||||
if _, exists := uniqMap[key]; !exists {
|
||||
matches = append(matches, key)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure results are sorted to ensure consistent results
|
||||
sort.Strings(matches)
|
||||
|
||||
assertEqual(t, expected, strings.Join(matches, ","), fmt.Sprintf("inline style detection \"%s\"", html))
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3client"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -100,6 +100,9 @@ func TestPOP3(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// allow for background delete when using rqlite driver
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
@@ -343,7 +346,7 @@ func insertEmailData(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
id, err := storage.Store(&bufBytes)
|
||||
id, err := storage.Store(&bufBytes, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -80,7 +80,7 @@ func Run() {
|
||||
|
||||
type message struct {
|
||||
ID string
|
||||
Size float64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
@@ -211,22 +211,33 @@ func handleClient(conn net.Conn) {
|
||||
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
|
||||
switch cmd {
|
||||
case "STAT":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
|
||||
case "LIST":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
if len(args) > 0 {
|
||||
arg, _ := getSafeArg(args, 0)
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil || nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", nr, int64(messages[nr-1].Size)))
|
||||
} else {
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
case "UIDL":
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
|
||||
190
internal/prometheus/metrics.go
Normal file
190
internal/prometheus/metrics.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Package prometheus provides Prometheus metrics for Mailpit
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Registry is the Prometheus registry for Mailpit metrics
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Metrics
|
||||
totalMessages prometheus.Gauge
|
||||
unreadMessages prometheus.Gauge
|
||||
databaseSize prometheus.Gauge
|
||||
messagesDeleted prometheus.Counter
|
||||
smtpAccepted prometheus.Counter
|
||||
smtpRejected prometheus.Counter
|
||||
smtpIgnored prometheus.Counter
|
||||
smtpAcceptedSize prometheus.Counter
|
||||
uptime prometheus.Gauge
|
||||
memoryUsage prometheus.Gauge
|
||||
tagCounters *prometheus.GaugeVec
|
||||
)
|
||||
|
||||
// InitMetrics initializes all Prometheus metrics
|
||||
func InitMetrics() {
|
||||
// Create metrics
|
||||
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages",
|
||||
Help: "Total number of messages in the database",
|
||||
})
|
||||
|
||||
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages_unread",
|
||||
Help: "Number of unread messages in the database",
|
||||
})
|
||||
|
||||
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_database_size_bytes",
|
||||
Help: "Size of the database in bytes",
|
||||
})
|
||||
|
||||
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_messages_deleted_total",
|
||||
Help: "Total number of messages deleted",
|
||||
})
|
||||
|
||||
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_total",
|
||||
Help: "Total number of SMTP messages accepted",
|
||||
})
|
||||
|
||||
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_rejected_total",
|
||||
Help: "Total number of SMTP messages rejected",
|
||||
})
|
||||
|
||||
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_ignored_total",
|
||||
Help: "Total number of SMTP messages ignored (duplicates)",
|
||||
})
|
||||
|
||||
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_size_bytes_total",
|
||||
Help: "Total size of accepted SMTP messages in bytes",
|
||||
})
|
||||
|
||||
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_uptime_seconds",
|
||||
Help: "Uptime of Mailpit in seconds",
|
||||
})
|
||||
|
||||
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_memory_usage_bytes",
|
||||
Help: "Memory usage in bytes",
|
||||
})
|
||||
|
||||
tagCounters = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "mailpit_tag_messages",
|
||||
Help: "Number of messages per tag",
|
||||
},
|
||||
[]string{"tag"},
|
||||
)
|
||||
|
||||
// Register metrics
|
||||
Registry.MustRegister(totalMessages)
|
||||
Registry.MustRegister(unreadMessages)
|
||||
Registry.MustRegister(databaseSize)
|
||||
Registry.MustRegister(messagesDeleted)
|
||||
Registry.MustRegister(smtpAccepted)
|
||||
Registry.MustRegister(smtpRejected)
|
||||
Registry.MustRegister(smtpIgnored)
|
||||
Registry.MustRegister(smtpAcceptedSize)
|
||||
Registry.MustRegister(uptime)
|
||||
Registry.MustRegister(memoryUsage)
|
||||
Registry.MustRegister(tagCounters)
|
||||
}
|
||||
|
||||
// UpdateMetrics updates all metrics with current values
|
||||
func UpdateMetrics() {
|
||||
info := stats.Load()
|
||||
|
||||
totalMessages.Set(float64(info.Messages))
|
||||
unreadMessages.Set(float64(info.Unread))
|
||||
databaseSize.Set(float64(info.DatabaseSize))
|
||||
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
uptime.Set(float64(info.RuntimeStats.Uptime))
|
||||
memoryUsage.Set(float64(info.RuntimeStats.Memory))
|
||||
|
||||
// Reset tag counters
|
||||
tagCounters.Reset()
|
||||
|
||||
// Update tag counters
|
||||
for tag, count := range info.Tags {
|
||||
tagCounters.WithLabelValues(tag).Set(float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the Prometheus handler & disables double compression in middleware
|
||||
func GetHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
})
|
||||
}
|
||||
|
||||
// StartUpdater starts the periodic metrics update routine
|
||||
func StartUpdater() {
|
||||
InitMetrics()
|
||||
UpdateMetrics()
|
||||
|
||||
// Start periodic updates
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
UpdateMetrics()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartSeparateServer starts a separate HTTP server for Prometheus metrics
|
||||
func StartSeparateServer() {
|
||||
StartUpdater()
|
||||
|
||||
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
|
||||
|
||||
// Create a dedicated mux for the metrics server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||
|
||||
// Create a dedicated server instance
|
||||
server := &http.Server{
|
||||
Addr: config.PrometheusListen,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// GetMode returns the Prometheus run mode
|
||||
func GetMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
|
||||
|
||||
switch {
|
||||
case mode == "false", mode == "":
|
||||
return "disabled"
|
||||
case mode == "true":
|
||||
return "integrated"
|
||||
default:
|
||||
return "separate"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ var (
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
@@ -37,6 +36,7 @@ type Triggers struct {
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
// swagger:model Trigger
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
|
||||
@@ -25,27 +25,55 @@ func autoForwardMessage(from string, data *[]byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {
|
||||
if config.TLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TLS dial error: %v", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func forward(from string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if config.SMTPForwardConfig.STARTTLS {
|
||||
conf := &tls.Config{ServerName: config.SMTPForwardConfig.Host} // #nosec
|
||||
|
||||
conf.InsecureSkipVerify = config.SMTPForwardConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
auth := forwardAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
|
||||
@@ -28,13 +28,13 @@ var (
|
||||
)
|
||||
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data)
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data, smtpUser)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) {
|
||||
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
|
||||
@@ -50,32 +50,20 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
// check / set the Return-Path based on SMTP from
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
if returnPath != from {
|
||||
if returnPath != "" {
|
||||
// replace Return-Path
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
|
||||
})
|
||||
} else {
|
||||
// add Return-Path
|
||||
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
|
||||
data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
|
||||
messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>")
|
||||
|
||||
// add a message ID if not set
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
messageID = shortuuid.New() + "@mailpit"
|
||||
// add unique ID
|
||||
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
|
||||
data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...)
|
||||
} else if config.IgnoreDuplicateIDs {
|
||||
if storage.MessageIDExists(messageID) {
|
||||
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
|
||||
@@ -108,29 +96,21 @@ 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, ", "))
|
||||
}
|
||||
|
||||
id, err := storage.Store(&data)
|
||||
id, err := storage.Store(&data, smtpUser)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
return "", err
|
||||
@@ -282,10 +262,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
smtpType := "no encryption"
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
if config.SMTPRequireSTARTTLS {
|
||||
smtpType = "STARTTLS required"
|
||||
} else if config.SMTPRequireTLS {
|
||||
if config.SMTPRequireTLS {
|
||||
smtpType = "SSL/TLS required"
|
||||
} else if config.SMTPRequireSTARTTLS {
|
||||
smtpType = "STARTTLS required"
|
||||
} else {
|
||||
smtpType = "STARTTLS optional"
|
||||
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {
|
||||
|
||||
@@ -59,27 +59,55 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {
|
||||
if config.TLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TLS dial error: %v", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Relay(from string, to []string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
c, err := createRelaySMTPClient(config.SMTPRelayConfig, addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if config.SMTPRelayConfig.STARTTLS {
|
||||
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host} // #nosec
|
||||
|
||||
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
auth := relayAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
|
||||
@@ -42,7 +42,7 @@ type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) er
|
||||
|
||||
// MsgIDHandler function called upon successful receipt of an email. Returns a message ID.
|
||||
// Results in a "250 2.0.0 Ok: queued as <message-id>" response.
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte) (string, error)
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error)
|
||||
|
||||
// HandlerRcpt function called on RCPT. Return accept status.
|
||||
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
|
||||
@@ -255,6 +255,7 @@ type session struct {
|
||||
xClientTrust bool // Trust XCLIENT from current IP address
|
||||
tls bool
|
||||
authenticated bool
|
||||
username *string // username, nil if not authenticated
|
||||
}
|
||||
|
||||
// Create new session from connection.
|
||||
@@ -362,6 +363,11 @@ func (s *session) serve() {
|
||||
var to []string
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
// Send banner.
|
||||
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName)
|
||||
|
||||
@@ -474,12 +480,7 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
if len(to) == s.srv.MaxRecipients {
|
||||
if len(to) >= s.srv.MaxRecipients {
|
||||
s.writef("452 4.5.3 Too many recipients")
|
||||
} else {
|
||||
accept := true
|
||||
@@ -550,7 +551,7 @@ loop:
|
||||
}
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
} else if s.srv.MsgIDHandler != nil {
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username)
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
@@ -883,7 +884,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
|
||||
}
|
||||
|
||||
@@ -916,6 +918,12 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
|
||||
if authenticated {
|
||||
uname := string(username)
|
||||
s.username = &uname
|
||||
} else {
|
||||
s.username = nil
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -7,23 +7,36 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
)
|
||||
|
||||
// Stores cached version along with its expiry time and error count.
|
||||
// Used to minimize repeated version lookups and track consecutive errors.
|
||||
type versionCache struct {
|
||||
// github version string
|
||||
value string
|
||||
// time to expire the cache
|
||||
expiry time.Time
|
||||
// count of consecutive errors
|
||||
errCount int
|
||||
}
|
||||
|
||||
var (
|
||||
// to prevent hammering Github for latest version
|
||||
latestVersionCache string
|
||||
// Version cache storing the latest GitHub version
|
||||
vCache versionCache
|
||||
|
||||
// StartedAt is set to the current ime when Mailpit starts
|
||||
startedAt time.Time
|
||||
|
||||
// sync mutex to prevent race condition with simultaneous requests
|
||||
mu sync.RWMutex
|
||||
|
||||
smtpAccepted float64
|
||||
smtpAcceptedSize float64
|
||||
smtpRejected float64
|
||||
smtpIgnored float64
|
||||
smtpAccepted uint64
|
||||
smtpAcceptedSize uint64
|
||||
smtpRejected uint64
|
||||
smtpIgnored uint64
|
||||
)
|
||||
|
||||
// AppInformation struct
|
||||
@@ -36,32 +49,38 @@ type AppInformation struct {
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize float64
|
||||
DatabaseSize uint64
|
||||
// Total number of messages in the database
|
||||
Messages float64
|
||||
Messages uint64
|
||||
// Total number of messages in the database
|
||||
Unread float64
|
||||
Unread uint64
|
||||
// Tags and message totals per tag
|
||||
Tags map[string]int64
|
||||
// Runtime statistics
|
||||
RuntimeStats struct {
|
||||
// Mailpit server uptime in seconds
|
||||
Uptime float64
|
||||
Uptime uint64
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
// Database runtime messages deleted
|
||||
MessagesDeleted float64
|
||||
MessagesDeleted uint64
|
||||
// Accepted runtime SMTP messages
|
||||
SMTPAccepted float64
|
||||
SMTPAccepted uint64
|
||||
// Total runtime accepted messages size in bytes
|
||||
SMTPAcceptedSize float64
|
||||
SMTPAcceptedSize uint64
|
||||
// Rejected runtime SMTP messages
|
||||
SMTPRejected float64
|
||||
SMTPRejected uint64
|
||||
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
|
||||
SMTPIgnored float64
|
||||
SMTPIgnored uint64
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates exponential backoff duration based on the error count.
|
||||
func getBackoff(errCount int) time.Duration {
|
||||
backoff := min(time.Duration(1<<errCount)*time.Minute, 30*time.Minute)
|
||||
return backoff
|
||||
}
|
||||
|
||||
// Load the current statistics
|
||||
func Load() AppInformation {
|
||||
info := AppInformation{}
|
||||
@@ -71,26 +90,42 @@ func Load() AppInformation {
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
|
||||
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
|
||||
info.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds())
|
||||
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
|
||||
info.RuntimeStats.SMTPAccepted = smtpAccepted
|
||||
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
|
||||
info.RuntimeStats.SMTPRejected = smtpRejected
|
||||
info.RuntimeStats.SMTPIgnored = smtpIgnored
|
||||
|
||||
if latestVersionCache != "" {
|
||||
info.LatestVersion = latestVersionCache
|
||||
if config.DisableVersionCheck {
|
||||
info.LatestVersion = "disabled"
|
||||
} else {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
latestVersionCache = latest
|
||||
mu.RLock()
|
||||
cacheValid := time.Now().Before(vCache.expiry)
|
||||
cacheValue := vCache.value
|
||||
mu.RUnlock()
|
||||
|
||||
// clear latest version cache after 5 minutes
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
latestVersionCache = ""
|
||||
}()
|
||||
if cacheValid {
|
||||
info.LatestVersion = cacheValue
|
||||
} else {
|
||||
mu.Lock()
|
||||
// Re-check after acquiring write lock in case another goroutine refreshed it
|
||||
if time.Now().Before(vCache.expiry) {
|
||||
info.LatestVersion = vCache.value
|
||||
} else {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
vCache = versionCache{value: latest, expiry: time.Now().Add(15 * time.Minute)}
|
||||
info.LatestVersion = latest
|
||||
} else {
|
||||
logger.Log().Errorf("Failed to fetch latest version: %v", err)
|
||||
vCache.errCount++
|
||||
vCache.value = ""
|
||||
vCache.expiry = time.Now().Add(getBackoff(vCache.errCount))
|
||||
info.LatestVersion = ""
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +147,7 @@ func Track() {
|
||||
func LogSMTPAccepted(size int) {
|
||||
mu.Lock()
|
||||
smtpAccepted = smtpAccepted + 1
|
||||
smtpAcceptedSize = smtpAcceptedSize + float64(size)
|
||||
smtpAcceptedSize = smtpAcceptedSize + uint64(size)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func dbCron() {
|
||||
if total == 0 {
|
||||
deletedPercent = 100
|
||||
} else {
|
||||
deletedPercent = deletedSize * 100 / total
|
||||
deletedPercent = float64(deletedSize * 100 / total)
|
||||
}
|
||||
// only vacuum the DB if at least 1% of mail storage size has been deleted
|
||||
if deletedPercent >= 1 {
|
||||
@@ -56,13 +56,13 @@ func pruneMessages() {
|
||||
start := time.Now()
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
var prunedSize uint64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
total := CountTotal()
|
||||
if total > float64(config.MaxAgeInHours) {
|
||||
if total > uint64(config.MaxAgeInHours) {
|
||||
offset := config.MaxMessages
|
||||
if config.DemoMode {
|
||||
offset = 500
|
||||
@@ -81,7 +81,7 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -110,7 +110,7 @@ func pruneMessages() {
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
|
||||
@@ -32,7 +32,7 @@ var (
|
||||
dbLastAction time.Time
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
dbEncoder *zstd.Encoder
|
||||
dbDecoder, _ = zstd.NewReader(nil)
|
||||
|
||||
temporaryFiles = []string{}
|
||||
@@ -40,11 +40,31 @@ var (
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
// dbEncoder
|
||||
var (
|
||||
dsn string
|
||||
err error
|
||||
)
|
||||
|
||||
if config.Compression > 0 {
|
||||
var compression zstd.EncoderLevel
|
||||
switch config.Compression {
|
||||
case 1:
|
||||
compression = zstd.SpeedFastest
|
||||
case 2:
|
||||
compression = zstd.SpeedDefault
|
||||
case 3:
|
||||
compression = zstd.SpeedBestCompression
|
||||
}
|
||||
dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Log().Debugf("[db] storing messages with compression: %s", compression.String())
|
||||
} else {
|
||||
logger.Log().Debug("[db] storing messages with no compression")
|
||||
}
|
||||
|
||||
p := config.Database
|
||||
|
||||
if p == "" {
|
||||
@@ -100,8 +120,13 @@ func InitDB() error {
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if sqlDriver == "sqlite" {
|
||||
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
|
||||
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
|
||||
if config.DisableWAL {
|
||||
// disable WAL mode for SQLite, allows NFS mounted DBs
|
||||
_, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;")
|
||||
} else {
|
||||
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
|
||||
_, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -185,65 +210,51 @@ func StatsGet() MailboxStats {
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() float64 {
|
||||
var total float64
|
||||
func CountTotal() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() float64 {
|
||||
var total float64
|
||||
func CountUnread() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() float64 {
|
||||
var total float64
|
||||
func CountRead() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// DbSize returns the size of the SQLite database.
|
||||
func DbSize() float64 {
|
||||
var total sql.NullFloat64
|
||||
func DbSize() uint64 {
|
||||
var total sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
|
||||
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
// IsUnread returns whether a message is unread or not.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&unread).
|
||||
Where("Read = ?", 0).
|
||||
Where("ID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return unread == 1
|
||||
return uint64(total.Float64)
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
|
||||
@@ -19,14 +19,15 @@ 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"
|
||||
)
|
||||
|
||||
// Store will save an email to the database tables.
|
||||
// The username is the authentication username of either the SMTP or HTTP client (blank for none).
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
func Store(body *[]byte, username *string) (string, error) {
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
// Parse message body with enmime
|
||||
@@ -44,13 +45,16 @@ func Store(body *[]byte) (string, error) {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
obj := DBMailSummary{
|
||||
obj := Metadata{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
if username != nil {
|
||||
obj.Username = *username
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
@@ -85,14 +89,14 @@ func Store(body *[]byte) (string, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := float64(len(*body))
|
||||
size := uint64(len(*body))
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
sql := fmt.Sprintf(`INSERT INTO %s
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
tenant("mailbox"),
|
||||
) // #nosec
|
||||
|
||||
@@ -102,10 +106,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
|
||||
}
|
||||
@@ -130,6 +149,11 @@ func Store(body *[]byte) (string, error) {
|
||||
tags = append(tags, obj.tagsFromPlusAddresses()...)
|
||||
}
|
||||
|
||||
// auto-tag by username if enabled
|
||||
if config.TagsUsername && username != nil && *username != "" {
|
||||
tags = append(tags, *username)
|
||||
}
|
||||
|
||||
// extract tags from search matches, and sort and extract unique tags
|
||||
tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))
|
||||
|
||||
@@ -175,41 +199,52 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
|
||||
q := sqlf.From(tenant("mailbox") + " m").
|
||||
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
|
||||
OrderBy("m.Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
OrderBy("m.Created DESC")
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(start)
|
||||
}
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var metadataJSON string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
em := MessageSummary{}
|
||||
var meta Metadata
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
|
||||
err := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
em.From = meta.From
|
||||
em.To = meta.To
|
||||
em.Cc = meta.Cc
|
||||
em.Bcc = meta.Bcc
|
||||
em.ReplyTo = meta.ReplyTo
|
||||
em.Username = meta.Username
|
||||
|
||||
em.Created = time.UnixMilli(int64(created))
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -254,12 +289,20 @@ func GetMessage(id string) (*Message, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var from *mail.Address
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
// Load metadata from DB
|
||||
meta, err := GetMetadata(id)
|
||||
if err != nil {
|
||||
meta = Metadata{}
|
||||
}
|
||||
|
||||
from := meta.From
|
||||
if from == nil {
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
@@ -277,7 +320,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -285,7 +328,6 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(int64(created))
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -304,10 +346,10 @@ func GetMessage(id string) (*Message, error) {
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: float64(len(raw)),
|
||||
Size: uint64(len(raw)),
|
||||
Text: env.Text,
|
||||
Username: meta.Username,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
@@ -345,7 +387,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 +398,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 +415,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 +424,18 @@ func GetMessageRaw(id string) ([]byte, error) {
|
||||
data = []byte(msg)
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll(data, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return raw, err
|
||||
if compressed == 1 {
|
||||
raw, err := dbDecoder.DecodeAll(data, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
@@ -440,7 +487,7 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
o.Size = uint64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
@@ -473,30 +520,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 +595,30 @@ func MarkAllUnread() error {
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
func MarkUnread(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessages deletes one or more messages in bulk
|
||||
@@ -599,16 +642,18 @@ func DeleteMessages(ids []string) error {
|
||||
defer rows.Close()
|
||||
|
||||
toDelete := []string{}
|
||||
var totalSize float64
|
||||
var totalSize uint64
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := rows.Scan(&id, &size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toDelete = append(toDelete, id)
|
||||
totalSize = totalSize + size
|
||||
totalSize = totalSize + uint64(size)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -645,7 +690,7 @@ func DeleteMessages(ids []string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
addDeletedSize(totalSize)
|
||||
|
||||
logMessagesDeleted(len(toDelete))
|
||||
|
||||
@@ -727,3 +772,17 @@ func DeleteAllMessages() error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetMetadata retrieves the metadata for a message by its ID
|
||||
func GetMetadata(id string) (Metadata, error) {
|
||||
var metadataJSON string
|
||||
row := db.QueryRow(fmt.Sprintf("SELECT Metadata FROM %s WHERE ID = ?", tenant("mailbox")), id)
|
||||
if err := row.Scan(&metadataJSON); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
var meta Metadata
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -54,13 +54,13 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -79,56 +79,64 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
compressionLevels := []int{0, 1, 2, 3}
|
||||
|
||||
setup(tenantID)
|
||||
for _, compressionLevel := range compressionLevels {
|
||||
t.Logf("Testing compression level: %d", compressionLevel)
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
config.Compression = compressionLevel
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, uint64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
// reset compression
|
||||
config.Compression = 1
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
@@ -143,7 +151,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
t.Logf("Testing message summary (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -177,7 +185,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
@@ -189,7 +197,7 @@ func BenchmarkImportMime(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -73,23 +73,22 @@ func ReindexAll() {
|
||||
continue
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
meta, _ := GetMetadata(id)
|
||||
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
meta.From = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
meta.From = &mail.Address{Name: env.GetHeader("From")}
|
||||
} else {
|
||||
meta.From = nil
|
||||
}
|
||||
meta.To = addressToSlice(env, "To")
|
||||
meta.Cc = addressToSlice(env, "Cc")
|
||||
meta.Bcc = addressToSlice(env, "Bcc")
|
||||
meta.ReplyTo = addressToSlice(env, "Reply-To")
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
MetadataJSON, err := json.Marshal(obj)
|
||||
MetadataJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
|
||||
5
internal/storage/schemas/1.23.0.sql
Normal file
5
internal/storage/schemas/1.23.0.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- CREATE Compressed COLUMN IN mailbox_data
|
||||
ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';
|
||||
|
||||
-- SET Compressed = 1 for all existing data
|
||||
UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1;
|
||||
@@ -39,12 +39,12 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
@@ -65,7 +65,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -100,6 +100,39 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
// SearchUnreadCount returns the number of unread messages matching a search.
|
||||
// This is run one at a time to allow connected browsers to be updated.
|
||||
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
tsStart := time.Now()
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var unread float64 // use float64 for rqlite compatibility
|
||||
|
||||
q = q.Where("Read = 0").Select(`COUNT(*)`)
|
||||
|
||||
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var ignore sql.NullString
|
||||
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed)
|
||||
|
||||
return int64(unread), err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
@@ -108,15 +141,15 @@ func DeleteSearch(search, timezone string) error {
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
ids := []string{}
|
||||
deleteSize := float64(0)
|
||||
deleteSize := uint64(0)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -128,7 +161,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
deleteSize = deleteSize + size
|
||||
deleteSize = deleteSize + uint64(size)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -214,7 +247,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
}
|
||||
|
||||
addDeletedSize(int64(deleteSize))
|
||||
addDeletedSize(deleteSize)
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
@@ -224,6 +257,47 @@ func DeleteSearch(search, timezone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSearchReadStatus marks all messages matching the search as read or unread
|
||||
func SetSearchReadStatus(search, timezone string, read bool) error {
|
||||
q := searchQueryBuilder(search, timezone).Where("Read = ?", !read)
|
||||
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if read {
|
||||
if err := MarkRead(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := MarkUnread(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
@@ -265,8 +339,8 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
lw = lw[1:]
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||
if !re.MatchString(w) {
|
||||
// ignore blank searches
|
||||
if len(w) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -445,7 +519,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
//
|
||||
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
|
||||
// M, m, Mb, MB and mb are treated as Megabytes.
|
||||
func sizeToBytes(v string) int64 {
|
||||
func sizeToBytes(v string) uint64 {
|
||||
v = strings.ToLower(v)
|
||||
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
|
||||
|
||||
@@ -463,15 +537,15 @@ func sizeToBytes(v string) int64 {
|
||||
}
|
||||
|
||||
if unit == "" {
|
||||
return int64(i)
|
||||
return uint64(i)
|
||||
}
|
||||
|
||||
if unit == "k" || unit == "kb" {
|
||||
return int64(i * 1024)
|
||||
return uint64(i * 1024)
|
||||
}
|
||||
|
||||
if unit == "m" || unit == "mb" {
|
||||
return int64(i * 1024 * 1024)
|
||||
return uint64(i * 1024 * 1024)
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
@@ -48,7 +48,7 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
if _, err := Store(&bufBytes, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -117,11 +117,11 @@ func TestSearchDelete100(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestEscPercentChar(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSizeToBytes(t *testing.T) {
|
||||
tests := map[string]int64{}
|
||||
tests := map[string]uint64{}
|
||||
tests["1m"] = 1048576
|
||||
tests["1mb"] = 1048576
|
||||
tests["1 M"] = 1048576
|
||||
|
||||
@@ -35,8 +35,8 @@ func SettingPut(k, v string) error {
|
||||
}
|
||||
|
||||
// The total deleted message size as an int64 value
|
||||
func getDeletedSize() float64 {
|
||||
var result sql.NullFloat64
|
||||
func getDeletedSize() uint64 {
|
||||
var result sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
err := sqlf.From(tenant("settings")).
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", "DeletedSize").
|
||||
@@ -47,11 +47,11 @@ func getDeletedSize() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() float64 {
|
||||
func totalMessagesSize() uint64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("mailbox")).
|
||||
Select("SUM(Size)").To(&result).
|
||||
@@ -61,11 +61,11 @@ func totalMessagesSize() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// AddDeletedSize will add the value to the DeletedSize setting
|
||||
func addDeletedSize(v int64) {
|
||||
func addDeletedSize(v uint64) {
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -28,18 +28,20 @@ type Message struct {
|
||||
// Message subject
|
||||
Subject string
|
||||
// List-Unsubscribe header information
|
||||
// swagger:ignore
|
||||
ListUnsubscribe ListUnsubscribe
|
||||
// Message date if set, else date received
|
||||
// Message RFC3339Nano date & time (if set), else date & time received
|
||||
// ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Date time.Time
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message body text
|
||||
Text string
|
||||
// Message body HTML
|
||||
HTML string
|
||||
// Message size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
// Inline message attachments
|
||||
Inline []Attachment
|
||||
// Message attachments
|
||||
@@ -59,7 +61,7 @@ type Attachment struct {
|
||||
// Content ID
|
||||
ContentID string
|
||||
// Size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
// MessageSummary struct for frontend messages
|
||||
@@ -84,12 +86,14 @@ type MessageSummary struct {
|
||||
ReplyTo []*mail.Address
|
||||
// Email subject
|
||||
Subject string
|
||||
// Created time
|
||||
// Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Created time.Time
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message size in bytes (total)
|
||||
Size float64
|
||||
Size uint64
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
// Message snippet includes up to 250 characters
|
||||
@@ -98,18 +102,19 @@ type MessageSummary struct {
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
ReplyTo []*mail.Address
|
||||
// Metadata struct for storing message metadata
|
||||
type Metadata struct {
|
||||
From *mail.Address `json:"From,omitempty"`
|
||||
To []*mail.Address `json:"To,omitempty"`
|
||||
Cc []*mail.Address `json:"Cc,omitempty"`
|
||||
Bcc []*mail.Address `json:"Bcc,omitempty"`
|
||||
ReplyTo []*mail.Address `json:"ReplyTo,omitempty"`
|
||||
Username string `json:"Username,omitempty"`
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
@@ -117,10 +122,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
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func LoadTagFilters() {
|
||||
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
|
||||
continue
|
||||
}
|
||||
if t.Tags == nil || len(t.Tags) == 0 {
|
||||
if len(t.Tags) == 0 {
|
||||
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func GetAllTags() []string {
|
||||
func GetAllTagsCount() map[string]int64 {
|
||||
var tags = make(map[string]int64)
|
||||
var name string
|
||||
var total int64
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
@@ -181,7 +181,7 @@ func GetAllTagsCount() map[string]int64 {
|
||||
GroupBy(tenant("message_tags.TagID")).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
tags[name] = int64(total)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func findTagsInRawMessage(message *[]byte) []string {
|
||||
}
|
||||
|
||||
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
|
||||
func (d DBMailSummary) tagsFromPlusAddresses() []string {
|
||||
func (d Metadata) tagsFromPlusAddresses() []string {
|
||||
tags := []string{}
|
||||
for _, c := range d.To {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestTags(t *testing.T) {
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -57,7 +57,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -124,7 +124,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
id, err = Store(&testTagEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -141,3 +141,48 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
func TestUsernameAutoTagging(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
username := "testuser"
|
||||
|
||||
t.Run("Auto-tagging enabled", func(t *testing.T) {
|
||||
config.TagsUsername = true
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auto-tagging disabled", func(t *testing.T) {
|
||||
config.TagsUsername = false
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
t.Errorf("Did not expect username '%s' in tags when disabled, got %v", username, msg.Tags)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if float64(total) != s.Total {
|
||||
if uint64(total) != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
|
||||
}
|
||||
|
||||
if float64(unread) != s.Unread {
|
||||
if uint64(unread) != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// for stats to prevent import cycle
|
||||
mu sync.RWMutex
|
||||
// StatsDeleted for counting the number of messages deleted
|
||||
StatsDeleted float64
|
||||
StatsDeleted uint64
|
||||
)
|
||||
|
||||
// AddTempFile adds a file to the slice of files to delete on exit
|
||||
@@ -88,7 +88,7 @@ func cleanString(str string) string {
|
||||
// LogMessagesDeleted logs the number of messages deleted
|
||||
func logMessagesDeleted(n int) {
|
||||
mu.Lock()
|
||||
StatsDeleted = StatsDeleted + float64(n)
|
||||
StatsDeleted = StatsDeleted + uint64(n)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
23
internal/tools/fs.go
Normal file
23
internal/tools/fs.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func IsDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -58,8 +58,10 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// UpdateMessageHeader scans a message for a header and updates its value if found.
|
||||
func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
// SetMessageHeader scans a message for a header and updates its value if found.
|
||||
// It does not consider multiple instances of the same header.
|
||||
// If not found it will add the header to the beginning of the message.
|
||||
func SetMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
@@ -90,13 +92,11 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[relay] replaced %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
|
||||
}
|
||||
return bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1), nil
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
// no header, so add one to beginning
|
||||
return append([]byte(header+": "+value+"\r\n"), msg...), nil
|
||||
}
|
||||
|
||||
// OverrideFromHeader scans a message for the From header and replaces it with a different email address.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// package Updater checks and downloads new versions
|
||||
// Package updater checks and downloads new versions
|
||||
package updater
|
||||
|
||||
import (
|
||||
|
||||
26
main.go
26
main.go
@@ -1,3 +1,4 @@
|
||||
// Package main is the entrypoint
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,26 +11,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
exec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// running directly
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) ||
|
||||
!strings.Contains(filepath.Base(os.Args[0]), "sendmail") {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked as "*sendmail*"
|
||||
// if the command executable contains "send" in the name (eg: sendmail), then run the sendmail command
|
||||
if strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), "send") {
|
||||
sendmail.Run()
|
||||
} else {
|
||||
// else run mailpit
|
||||
cmd.Execute()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns a lowercase string stripped of the file extension (if exists).
|
||||
// Used for detecting Windows commands which ignores letter casing and `.exe`.
|
||||
// eg: "MaIlpIT.Exe" returns "mailpit"
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
return strings.TrimSuffix(s, filepath.Ext(s))
|
||||
}
|
||||
|
||||
5848
package-lock.json
generated
5848
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "mailpit",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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 internal/htmlcheck/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",
|
||||
"lint": "eslint --max-warnings 0 && prettier -c .",
|
||||
"lint-fix": "eslint --fix && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
@@ -16,10 +19,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",
|
||||
@@ -33,6 +36,16 @@
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
"esbuild-sass-plugin": "^3.0.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-vue": "^10.2.0",
|
||||
"neostandard": "^0.12.1",
|
||||
"prettier": "^3.5.3"
|
||||
},
|
||||
"prettier":{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > -1 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ type webUIConfiguration struct {
|
||||
BlockedRecipients string
|
||||
// Overrides the "From" address for all relayed messages
|
||||
OverrideFrom string
|
||||
// Preserve the original Message-IDs when relaying messages
|
||||
PreserveMessageIDs bool
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
// swagger:ignore
|
||||
RecipientAllowlist string
|
||||
@@ -75,6 +78,9 @@ type webUIConfiguration struct {
|
||||
|
||||
// Whether messages with duplicate IDs are ignored
|
||||
DuplicatesIgnored bool
|
||||
|
||||
// Whether the delete button should be hidden
|
||||
HideDeleteAllButton bool
|
||||
}
|
||||
|
||||
// Web UI configuration response
|
||||
@@ -114,6 +120,8 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
conf.MessageRelay.PreserveMessageIDs = config.SMTPRelayConfig.PreserveMessageIDs
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
@@ -121,6 +129,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.ChaosEnabled = chaos.Enabled
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
conf.HideDeleteAllButton = config.HideDeleteAllButton
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
// ChaosTriggers is the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
// ChaosTriggers are the Chaos triggers
|
||||
type ChaosTriggers chaos.Triggers
|
||||
|
||||
// Response for the Chaos triggers configuration
|
||||
|
||||
@@ -40,18 +40,21 @@ type messagesSummaryResponse struct {
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
// Total number of messages in mailbox
|
||||
Total float64 `json:"total"`
|
||||
Total uint64 `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
Unread float64 `json:"unread"`
|
||||
Unread uint64 `json:"unread"`
|
||||
|
||||
// Legacy - now undocumented in API specs but left for backwards compatibility.
|
||||
// Removed from API documentation 2023-07-12
|
||||
// swagger:ignore
|
||||
Count float64 `json:"count"`
|
||||
Count uint64 `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
MessagesCount uint64 `json:"messages_count"`
|
||||
|
||||
// Total number of unread messages matching current query
|
||||
MessagesUnreadCount uint64 `json:"messages_unread"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
@@ -95,11 +98,12 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Count = uint64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
res.MessagesUnreadCount = stats.Unread
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
@@ -118,22 +122,36 @@ type setReadStatusParams struct {
|
||||
// example: true
|
||||
Read bool
|
||||
|
||||
// Array of message database IDs
|
||||
// Optional array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// default: []
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
|
||||
// Optional messages matching a search
|
||||
//
|
||||
// required: false
|
||||
// example: tag:backups
|
||||
Search string
|
||||
}
|
||||
|
||||
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type string
|
||||
TZ string `json:"tz"`
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
// If no IDs are provided then all messages are updated.
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
|
||||
//
|
||||
// # Set read status
|
||||
//
|
||||
// If no IDs are provided then all messages are updated.
|
||||
// You can optionally provide an array of IDs or a search string.
|
||||
// If neither IDs nor search is provided then all mailbox messages are updated.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -150,8 +168,9 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
Read bool
|
||||
IDs []string
|
||||
Search string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
@@ -161,8 +180,20 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
search := data.Search
|
||||
|
||||
if len(ids) == 0 {
|
||||
if len(ids) > 0 && search != "" {
|
||||
httpError(w, "You may specify either IDs or a search query, not both")
|
||||
return
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
err := storage.SetSearchReadStatus(search, r.URL.Query().Get("tz"), data.Read)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
@@ -178,18 +209,14 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := storage.MarkRead(ids); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkUnread(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := storage.MarkUnread(ids); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +292,7 @@ type searchParams struct {
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: 0
|
||||
// type integer
|
||||
Start string `json:"start"`
|
||||
|
||||
@@ -272,10 +300,11 @@ type searchParams struct {
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: 50
|
||||
// type integer
|
||||
Limit string `json:"limit"`
|
||||
|
||||
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
@@ -320,12 +349,20 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = float64(results)
|
||||
res.Count = uint64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = uint64(results)
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
unread, err := storage.SearchUnreadCount(search, r.URL.Query().Get("tz"), beforeTS)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res.MessagesUnreadCount = uint64(unread)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
// swagger:parameters HTMLCheckParams
|
||||
|
||||
@@ -133,7 +133,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fromAddresses, err := m.Header.AddressList("From")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
httpError(w, "Failed: unable to parse From header: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,19 +170,20 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// update message date
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
|
||||
msg, err = tools.SetMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
// update Message-Id with unique ID
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
if !config.SMTPRelayConfig.PreserveMessageIDs {
|
||||
// replace the Message-ID header with unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := smtpd.Relay(from, data.To, msg); err != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
// swagger:parameters SendMessageParams
|
||||
@@ -175,7 +175,12 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := data.Send(r.RemoteAddr)
|
||||
var httpAuthUser *string
|
||||
if user, _, ok := r.BasicAuth(); ok {
|
||||
httpAuthUser = &user
|
||||
}
|
||||
|
||||
id, err := data.Send(r.RemoteAddr, httpAuthUser)
|
||||
|
||||
if err != nil {
|
||||
httpJSONError(w, err.Error())
|
||||
@@ -190,7 +195,7 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Send will validate the message structure and attempt to send to Mailpit.
|
||||
// It returns a sending summary or an error.
|
||||
func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
func (d SendRequest) Send(remoteAddr string, httpAuthUser *string) (string, error) {
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
|
||||
@@ -302,5 +307,5 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
return "", fmt.Errorf("error building message: %s", err.Error())
|
||||
}
|
||||
|
||||
return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes())
|
||||
return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes(), httpAuthUser)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ func embedController(w http.ResponseWriter, r *http.Request) {
|
||||
p = p + "index.html"
|
||||
}
|
||||
|
||||
p = strings.TrimLeft(p, config.Webroot) // server webroot config
|
||||
p = path.Join("ui", p) // add go:embed path to path prefix
|
||||
p = strings.TrimPrefix(p, config.Webroot) // server webroot config
|
||||
p = path.Join("ui", p) // add go:embed path to path prefix
|
||||
|
||||
b, err := distFS.ReadFile(p)
|
||||
if err != nil {
|
||||
@@ -71,6 +71,8 @@ func contentType(p string) string {
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(p, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(p, ".woff"):
|
||||
return "font/woff"
|
||||
case strings.HasSuffix(p, ".woff2"):
|
||||
return "font/woff2"
|
||||
default:
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
@@ -158,7 +159,7 @@ func apiRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
|
||||
@@ -182,6 +183,13 @@ func apiRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
|
||||
|
||||
// Prometheus metrics (if enabled and using existing server)
|
||||
if prometheus.GetMode() == "integrated" {
|
||||
r.HandleFunc(config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
prometheus.GetHandler().ServeHTTP(w, r)
|
||||
})).Methods("GET")
|
||||
}
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
@@ -198,6 +206,48 @@ func basicAuthResponse(w http.ResponseWriter) {
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
|
||||
// It can use dedicated send API authentication or accept any credentials based on configuration
|
||||
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// If send API auth accept any is enabled, bypass all authentication
|
||||
if config.SendAPIAuthAcceptAny {
|
||||
// Temporarily disable UI auth for this request
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
// Call the standard middleware
|
||||
middleWareFunc(fn)(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If Send API credentials are configured, only accept those credentials
|
||||
if auth.SendAPICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.SendAPICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Valid Send API credentials - bypass UI auth and call function directly
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
middleWareFunc(fn)(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// No Send API credentials configured - fall back to UI auth
|
||||
middleWareFunc(fn)(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
@@ -239,7 +289,9 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
// Check basic authentication headers if configured.
|
||||
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight.
|
||||
if !(AccessControlAllowOrigin != "" && r.Method == http.MethodOptions) && auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -253,7 +305,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
if config.DisableHTTPCompression || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
fn(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -24,6 +26,18 @@ var (
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// Shared test message structure for consistency
|
||||
testSendMessage = map[string]interface{}{
|
||||
"From": map[string]string{
|
||||
"Email": "test@example.com",
|
||||
},
|
||||
"To": []map[string]string{
|
||||
{"Email": "recipient@example.com"},
|
||||
},
|
||||
"Subject": "Test",
|
||||
"Text": "Test message",
|
||||
}
|
||||
)
|
||||
|
||||
func TestAPIv1Messages(t *testing.T) {
|
||||
@@ -312,6 +326,157 @@ func TestAPIv1Send(t *testing.T) {
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
|
||||
func TestSendAPIAuthMiddleware(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
// Test 1: Send API with accept-any enabled (should bypass all auth)
|
||||
t.Run("SendAPIAuthAcceptAny", func(t *testing.T) {
|
||||
// Set up UI auth and enable accept-any for send API
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
}()
|
||||
|
||||
// Enable accept-any for send API
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
|
||||
// Set up UI auth that would normally block requests
|
||||
testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("testuser:" + string(testHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Should succeed without any auth headers
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
_, err := clientPost(ts.URL+"/api/v1/send", string(jsonData))
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with accept-any, got error: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Send API with dedicated credentials
|
||||
t.Run("SendAPIWithDedicatedCredentials", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
config.SendAPIAuthAcceptAny = false
|
||||
|
||||
// Set up UI auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
|
||||
// Set up dedicated Send API auth
|
||||
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
|
||||
auth.SetSendAPIAuth("senduser:" + string(sendHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
|
||||
// Should succeed with correct Send API credentials
|
||||
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "sendpass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with correct Send API credentials, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Should fail with wrong Send API credentials
|
||||
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "wrongpass")
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail with wrong Send API credentials")
|
||||
}
|
||||
|
||||
// Should fail with UI credentials when Send API credentials are set
|
||||
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail with UI credentials when Send API credentials are required")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Send API fallback to UI auth when no Send API auth is configured
|
||||
t.Run("SendAPIFallbackToUIAuth", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
config.SendAPIAuthAcceptAny = false
|
||||
auth.SendAPICredentials = nil
|
||||
|
||||
// Set up only UI auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
|
||||
// Should succeed with UI credentials when no Send API auth is configured
|
||||
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with UI credentials when no Send API auth configured, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Should fail without any credentials
|
||||
_, err = clientPost(ts.URL+"/api/v1/send", string(jsonData))
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail without credentials when UI auth is required")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Regular API endpoints should not be affected by Send API auth settings
|
||||
t.Run("RegularAPINotAffectedBySendAPIAuth", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
// Set up UI auth and Send API auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
|
||||
auth.SetSendAPIAuth("senduser:" + string(sendHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Regular API endpoint should require UI credentials, not Send API credentials
|
||||
_, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "uiuser", "uipass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected regular API to work with UI credentials, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Regular API endpoint should fail with Send API credentials
|
||||
_, err = clientGetWithAuth(ts.URL+"/api/v1/messages", "senduser", "sendpass")
|
||||
if err == nil {
|
||||
t.Error("Expected regular API to fail with Send API credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
@@ -340,8 +505,8 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, float64(unread), m.Unread, "wrong unread count")
|
||||
assertEqual(t, float64(total), m.Total, "wrong total count")
|
||||
assertEqual(t, uint64(unread), m.Unread, "wrong unread count")
|
||||
assertEqual(t, uint64(total), m.Total, "wrong total count")
|
||||
}
|
||||
|
||||
func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
@@ -361,7 +526,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, float64(count), m.MessagesCount, "wrong search results count")
|
||||
assertEqual(t, uint64(count), m.MessagesCount, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
@@ -387,7 +552,7 @@ func insertEmailData(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
id, err := storage.Store(&bufBytes)
|
||||
id, err := storage.Store(&bufBytes, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -521,6 +686,59 @@ func clientPost(url, body string) ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPostWithAuth(url, body, username, password string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("POST", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientGetWithAuth(url, username, password string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
<script>
|
||||
import CommonMixins from './mixins/CommonMixins'
|
||||
import Favicon from './components/Favicon.vue'
|
||||
import Notifications from './components/Notifications.vue'
|
||||
import EditTags from './components/EditTags.vue'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
import CommonMixins from "./mixins/CommonMixins";
|
||||
import Favicon from "./components/AppFavicon.vue";
|
||||
import AppBadge from "./components/AppBadge.vue";
|
||||
import Notifications from "./components/AppNotifications.vue";
|
||||
import EditTags from "./components/EditTags.vue";
|
||||
import { mailbox } from "./stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
Favicon,
|
||||
AppBadge,
|
||||
Notifications,
|
||||
EditTags
|
||||
EditTags,
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
mailbox.uiConfig = response.data
|
||||
|
||||
if (mailbox.uiConfig.Label) {
|
||||
document.title = document.title + ' - ' + mailbox.uiConfig.Label
|
||||
} else {
|
||||
document.title = document.title + ' - ' + location.hostname
|
||||
}
|
||||
})
|
||||
},
|
||||
mixins: [CommonMixins],
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
// hide mobile menu on URL change
|
||||
this.hideNav()
|
||||
}
|
||||
this.hideNav();
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
beforeMount() {
|
||||
// load global config
|
||||
this.get(this.resolve("/api/v1/webui"), false, (response) => {
|
||||
mailbox.uiConfig = response.data;
|
||||
|
||||
if (mailbox.uiConfig.Label) {
|
||||
document.title = document.title + " - " + mailbox.uiConfig.Label;
|
||||
} else {
|
||||
document.title = document.title + " - " + location.hostname;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Favicon />
|
||||
<AppBadge />
|
||||
<Notifications />
|
||||
<EditTags />
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
import mitt from 'mitt';
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { createApp } from "vue";
|
||||
import mitt from "mitt";
|
||||
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
import 'vue-css-donut-chart/src/styles/main.css'
|
||||
import "./assets/styles.scss";
|
||||
import "bootstrap-icons/font/bootstrap-icons.scss";
|
||||
import "bootstrap";
|
||||
import "vue-css-donut-chart/src/styles/main.css";
|
||||
|
||||
const app = createApp(App)
|
||||
const app = createApp(App);
|
||||
|
||||
// Global event bus used to subscribe to websocket events
|
||||
// such as message deletes, updates & truncation.
|
||||
const eventBus = mitt()
|
||||
app.provide('eventBus', eventBus)
|
||||
const eventBus = mitt();
|
||||
app.provide("eventBus", eventBus);
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
||||
@@ -15,7 +15,9 @@ $font-family-sans-serif:
|
||||
|
||||
$link-decoration: none;
|
||||
$primary: #2c3e50;
|
||||
$secondary: #495057;
|
||||
$list-group-disabled-color: #adb5bd;
|
||||
$enable-negative-margins: true;
|
||||
$body-color-dark: #e7eaed;
|
||||
$offcanvas-border-width: 0;
|
||||
$body-color: #080808;
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
.token.property {
|
||||
color: #ee6969;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #9c9c9c;
|
||||
|
||||
&:hover {
|
||||
color: $body-color-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-spaces-nowrap {
|
||||
@@ -228,27 +236,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
#message-page {
|
||||
#message-page,
|
||||
#MessageList {
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.message:not(.active) {
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
> div {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
@@ -354,12 +359,19 @@ body.blur {
|
||||
}
|
||||
}
|
||||
|
||||
/* PrismJS 1.29.0 - modified!
|
||||
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
||||
// HighlightJS for HTML rendering
|
||||
@import "highlight.js/styles/github.css";
|
||||
|
||||
@include color-mode(dark) {
|
||||
@import "highlight.js/scss/github-dark";
|
||||
|
||||
.hljs {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
// color: #000;
|
||||
// background: 0 0;
|
||||
font-size: 0.85em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
@@ -408,72 +420,6 @@ pre[class*="language-"] {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.block-comment,
|
||||
.token.cdata,
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: #7d8b99;
|
||||
}
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
}
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
.token.deleted,
|
||||
.token.function-name,
|
||||
.token.number,
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: #c92c2c;
|
||||
}
|
||||
.token.attr-name,
|
||||
.token.builtin,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.inserted,
|
||||
.token.selector,
|
||||
.token.string {
|
||||
color: #2f9c0a;
|
||||
}
|
||||
.token.entity,
|
||||
.token.operator,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.class-name,
|
||||
.token.keyword {
|
||||
color: #1990b8;
|
||||
}
|
||||
.token.important,
|
||||
.token.regex {
|
||||
color: #e90;
|
||||
}
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.important {
|
||||
font-weight: 400;
|
||||
}
|
||||
.token.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
// .token.entity {
|
||||
// cursor: help;
|
||||
// }
|
||||
.token.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
pre[class*="language-"]::after,
|
||||
pre[class*="language-"]::before {
|
||||
@@ -481,24 +427,3 @@ pre[class*="language-"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers {
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
}
|
||||
pre[class*="language-"][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Number,
|
||||
loading: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader" v-if="loading > 0">
|
||||
<div v-if="loading > 0" class="loader">
|
||||
<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>
|
||||
|
||||
@@ -1,75 +1,83 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import Settings from '../components/Settings.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import Settings from "./AppSettings.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
Settings,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo() {
|
||||
this.get(this.resolve('/api/v1/info'), false, (response) => {
|
||||
mailbox.appInfo = response.data
|
||||
this.modal('AppInfoModal').show()
|
||||
})
|
||||
this.get(this.resolve("/api/v1/info"), false, (response) => {
|
||||
mailbox.appInfo = response.data;
|
||||
this.modal("AppInfoModal").show();
|
||||
});
|
||||
},
|
||||
|
||||
requestNotifications() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notifications")
|
||||
alert("This browser does not support desktop notifications");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
mailbox.notificationsEnabled = true;
|
||||
}
|
||||
|
||||
this.modal('EnableNotificationsModal').hide()
|
||||
})
|
||||
this.modal("EnableNotificationsModal").hide();
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo()">
|
||||
<button class="text-muted btn btn-sm" @click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal"
|
||||
title="Mailpit UI settings"
|
||||
>
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
|
||||
<button
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled"
|
||||
class="btn btn-sm btn-outline-secondary float-end me-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal"
|
||||
title="Enable browser notifications"
|
||||
>
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,12 +85,17 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AppInfoModal"
|
||||
class="modal modal-xl fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats">
|
||||
<div v-if="mailbox.appInfo.RuntimeStats" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
||||
<h5 id="AppInfoModalLabel" class="modal-title">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
</h5>
|
||||
@@ -91,20 +104,30 @@ export default {
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
|
||||
<div class="col">
|
||||
<div class="alert alert-warning mb-3">
|
||||
There might be a newer version available. The check failed.
|
||||
<div v-if="mailbox.appInfo.LatestVersion != 'disabled'">
|
||||
<div v-if="mailbox.appInfo.LatestVersion == ''" class="row g-3">
|
||||
<div class="col">
|
||||
<div class="alert alert-warning mb-3">
|
||||
There might be a newer version available. The check failed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3"
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
|
||||
<div class="col">
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
</a>
|
||||
<div
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||
class="row g-3"
|
||||
>
|
||||
<div class="col">
|
||||
<a
|
||||
class="btn btn-warning d-block mb-3"
|
||||
:href="
|
||||
'https://github.com/axllent/mailpit/releases/tag/' +
|
||||
mailbox.appInfo.LatestVersion
|
||||
"
|
||||
>
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is
|
||||
available.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
@@ -115,23 +138,30 @@ export default {
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://github.com/axllent/mailpit"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank">
|
||||
<a
|
||||
class="btn btn-primary w-100"
|
||||
href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">Database size</div>
|
||||
<div class="card-body text-secondary">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
<div class="card-body text-muted">
|
||||
<h5 class="card-title">
|
||||
{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +169,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>
|
||||
@@ -152,55 +182,46 @@ export default {
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo()">
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" @click="loadInfo()">
|
||||
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>
|
||||
<td>
|
||||
Mailpit up since
|
||||
</td>
|
||||
<td>Mailpit up since</td>
|
||||
<td>
|
||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Messages deleted
|
||||
</td>
|
||||
<td>Messages deleted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages accepted
|
||||
</td>
|
||||
<td>SMTP messages accepted</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-secondary">
|
||||
<small class="text-muted">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
getFileSize(
|
||||
mailbox.appInfo.RuntimeStats.SMTPAcceptedSize,
|
||||
)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages rejected
|
||||
</td>
|
||||
<td>SMTP messages rejected</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
||||
<td>
|
||||
SMTP messages ignored
|
||||
</td>
|
||||
<td>SMTP messages ignored</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
||||
</td>
|
||||
@@ -208,12 +229,9 @@ export default {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@@ -222,26 +240,30 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div
|
||||
id="EnableNotificationsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<h5 id="EnableNotificationsModalLabel" class="modal-title">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the
|
||||
notifications.
|
||||
<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to
|
||||
be able to receive the notifications.
|
||||
</p>
|
||||
</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" v-on:click="requestNotifications">
|
||||
<button type="button" class="btn btn-success" @click="requestNotifications">
|
||||
Enable notifications
|
||||
</button>
|
||||
</div>
|
||||
57
server/ui-src/components/AppBadge.vue
Normal file
57
server/ui-src/components/AppBadge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
116
server/ui-src/components/AppFavicon.vue
Normal file
116
server/ui-src/components/AppFavicon.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script>
|
||||
import { mailbox } from "../stores/mailbox.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: "#ffffff",
|
||||
iconBgColor: "#dd0000",
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread;
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + "k";
|
||||
}
|
||||
|
||||
return i;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconProcessing = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate();
|
||||
}, this.iconTimeout);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]');
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false;
|
||||
this.favicon.href = this.iconPath;
|
||||
return;
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize;
|
||||
// Draw badge text
|
||||
let textPaddingX = 7;
|
||||
const textPaddingY = 3;
|
||||
|
||||
const strlen = this.count.toString().length;
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4;
|
||||
fontSize = strlen > 3 ? 30 : 36;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Draw base icon
|
||||
const icon = new Image();
|
||||
icon.src = this.iconPath;
|
||||
await icon.decode();
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64);
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`;
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "top";
|
||||
const textMetrics = ctx.measureText(this.count);
|
||||
|
||||
// Draw badge
|
||||
const paddingX = 7;
|
||||
const paddingY = 4;
|
||||
const cornerRadius = 8;
|
||||
|
||||
const width = textMetrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = canvas.width - width;
|
||||
const y = canvas.height - height - 1;
|
||||
|
||||
ctx.fillStyle = this.iconBgColor;
|
||||
ctx.roundRect(x, y, width, height, cornerRadius);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = this.iconTextColor;
|
||||
ctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);
|
||||
|
||||
this.iconProcessing = false;
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
289
server/ui-src/components/AppNotifications.vue
Normal file
289
server/ui-src/components/AppNotifications.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { Toast } from "bootstrap";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById("app");
|
||||
if (d) {
|
||||
this.version = d.dataset.version;
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`);
|
||||
|
||||
this.socketBreakReset();
|
||||
this.connect();
|
||||
|
||||
mailbox.notificationsSupported =
|
||||
window.isSecureContext && "Notification" in window && Notification.permission !== "denied";
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === "granted";
|
||||
|
||||
this.errorNotificationCron();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI);
|
||||
ws.onmessage = (e) => {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type === "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data);
|
||||
|
||||
for (const i in response.Data.Tags) {
|
||||
if (
|
||||
mailbox.tags.findIndex((e) => {
|
||||
return e.toLowerCase() === response.Data.Tags[i].toLowerCase();
|
||||
}) < 0
|
||||
) {
|
||||
mailbox.tags.push(response.Data.Tags[i]);
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true;
|
||||
const from = response.Data.From !== null ? response.Data.From.Address : "[unknown]";
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||
this.setMessageToast(response.Data);
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => {
|
||||
this.pauseNotifications = false;
|
||||
}, 2000);
|
||||
}
|
||||
} else if (response.Type === "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type === "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total;
|
||||
mailbox.unread = response.Data.Unread;
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version !== response.Data.Version) {
|
||||
location.reload();
|
||||
}
|
||||
} else if (response.Type === "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data);
|
||||
} else if (response.Type === "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data);
|
||||
} else if (response.Type === "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate");
|
||||
} else if (response.Type === "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true;
|
||||
this.socketLastConnection = Date.now();
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false;
|
||||
mailbox.refresh = true; // trigger refresh
|
||||
window.setTimeout(() => {
|
||||
mailbox.refresh = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection === 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log("Unable to connect to websocket, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++;
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false;
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log("Unstable websocket connection, disabling websocket support");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true;
|
||||
} else {
|
||||
this.reconnectRefresh = false;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(); // reconnect
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0;
|
||||
this.socketBreakReset();
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
const options = {
|
||||
body: message,
|
||||
icon: this.resolve("/notification.png"),
|
||||
};
|
||||
|
||||
(() => new Notification(title, options))();
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastMessage = m;
|
||||
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
el.addEventListener("hidden.bs.toast", () => {
|
||||
this.toastMessage = false;
|
||||
});
|
||||
|
||||
Toast.getOrCreateInstance(el).show();
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById("messageToast");
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide();
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000; // expire after 5s
|
||||
this.clientErrors.push(d);
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
this.errorNotificationCron();
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div
|
||||
v-for="(error, i) in clientErrors"
|
||||
:key="'error_' + i"
|
||||
class="toast show"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="toast-header">
|
||||
<svg
|
||||
class="bd-placeholder-img rounded me-2"
|
||||
width="20"
|
||||
height="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
focusable="false"
|
||||
>
|
||||
<rect width="100%" height="100%" :fill="error.Level === 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div v-if="toastMessage" class="toast-header">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink
|
||||
:to="'/view/' + toastMessage.ID"
|
||||
class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast"
|
||||
>
|
||||
<template v-if="toastMessage.Subject !== ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else> [ no subject ] </template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
381
server/ui-src/components/AppSettings.vue
Normal file
381
server/ui-src/components/AppSettings.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import timezones from "timezones-list";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem("theme") ? localStorage.getItem("theme") : "auto",
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v === "auto") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
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() {
|
||||
this.setTheme();
|
||||
this.$nextTick(() => {
|
||||
Tags.init("select.tz");
|
||||
});
|
||||
|
||||
mailbox.skipConfirmations = !!localStorage.getItem("skip-confirmations");
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (this.theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
||||
} else {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="SettingsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="SettingsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="SettingsModalLabel" class="modal-title">Mailpit settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="mailbox.uiConfig.ChaosEnabled" id="myTab" class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
id="ui-tab"
|
||||
class="nav-link active"
|
||||
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
|
||||
id="chaos-tab"
|
||||
class="nav-link"
|
||||
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
|
||||
id="ui-tab-pane"
|
||||
class="tab-pane fade show active"
|
||||
role="tabpanel"
|
||||
aria-labelledby="ui-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="my-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select id="theme" v-model="theme" class="form-select">
|
||||
<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
|
||||
id="timezone"
|
||||
v-model="mailbox.timeZone"
|
||||
class="form-select tz"
|
||||
data-allow-same="true"
|
||||
>
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :key="t" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="tagColors"
|
||||
v-model="mailbox.showTagColors"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<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
|
||||
id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<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
|
||||
id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mailbox.uiConfig.SpamAssassin" class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<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
|
||||
id="skip-confirmations"
|
||||
v-model="mailbox.skipConfirmations"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<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
|
||||
v-if="mailbox.uiConfig.ChaosEnabled"
|
||||
id="chaos-tab-pane"
|
||||
class="tab-pane fade"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chaos-tab"
|
||||
tabindex="0"
|
||||
>
|
||||
<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
|
||||
v-model.number="chaosConfig.Sender.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Sender.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</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
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Recipient.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</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
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="400"
|
||||
max="599"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="chaosConfig.Authentication.Probability"
|
||||
type="range"
|
||||
class="form-range mt-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</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="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@@ -9,74 +9,83 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
editableTags: [],
|
||||
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
|
||||
validTagRe: /^([a-zA-Z0-9\- ._]){1,}$/,
|
||||
tagToDelete: false,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'mailbox.tags': {
|
||||
"mailbox.tags": {
|
||||
handler(tags) {
|
||||
this.editableTags = []
|
||||
this.editableTags = [];
|
||||
tags.forEach((t) => {
|
||||
this.editableTags.push({ before: t, after: t })
|
||||
})
|
||||
this.editableTags.push({ before: t, after: t });
|
||||
});
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
validTag(t) {
|
||||
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
|
||||
return false
|
||||
if (!t.after.match(/^([a-zA-Z0-9\- _.]){1,}$/)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = t.after.toLowerCase()
|
||||
const lower = t.after.toLowerCase();
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
|
||||
return false
|
||||
if (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
|
||||
renameTag(t) {
|
||||
if (!this.validTag(t) || t.before == t.after) {
|
||||
return
|
||||
if (!this.validTag(t) || t.before === t.after) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
|
||||
// the API triggers a reload via websockets
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
deleteTag() {
|
||||
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
|
||||
// the API triggers a reload via websockets
|
||||
this.tagToDelete = false
|
||||
})
|
||||
this.tagToDelete = false;
|
||||
});
|
||||
},
|
||||
|
||||
resetTagEdit(t) {
|
||||
for (let x = 0; x < this.editableTags.length; x++) {
|
||||
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
|
||||
this.editableTags[x].after = this.editableTags[x].before
|
||||
if (
|
||||
this.editableTags[x].before !== t.before &&
|
||||
this.editableTags[x].before !== this.editableTags[x].after
|
||||
) {
|
||||
this.editableTags[x].after = this.editableTags[x].before;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div
|
||||
id="EditTagsModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="EditTagsModalLabel"
|
||||
aria-hidden="true"
|
||||
data-bs-keyboard="false"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
|
||||
<h5 id="EditTagsModalLabel" class="modal-title">Edit tags</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -84,29 +93,34 @@ export default {
|
||||
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
|
||||
itself, and not any messages which had the tag.
|
||||
</p>
|
||||
<div class="mb-3" v-for="t in editableTags">
|
||||
<div v-for="(t, i) in editableTags" :key="'tag_' + i" class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
|
||||
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
|
||||
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)">
|
||||
<button v-if="t.before != t.after" class="btn btn-success"
|
||||
@click="renameTag(t)">Save</button>
|
||||
<input
|
||||
v-model.trim="t.after"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="!validTag(t) ? 'is-invalid' : ''"
|
||||
aria-describedby="inputGroupPrepend"
|
||||
required
|
||||
@keydown.enter="renameTag(t)"
|
||||
@keydown.esc="t.after = t.before"
|
||||
@focus="resetTagEdit(t)"
|
||||
/>
|
||||
<button v-if="t.before != t.after" class="btn btn-success" @click="renameTag(t)">
|
||||
Save
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="btn btn-outline-danger"
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
|
||||
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
|
||||
<template v-if="tagToDelete == t">
|
||||
Confirm?
|
||||
</template>
|
||||
<template v-else>
|
||||
Delete
|
||||
</template>
|
||||
@click="!tagToDelete ? (tagToDelete = t) : deleteTag()"
|
||||
@blur="tagToDelete = false"
|
||||
>
|
||||
<template v-if="tagToDelete == t"> Confirm? </template>
|
||||
<template v-else> Delete </template>
|
||||
</button>
|
||||
</template>
|
||||
<div class="invalid-feedback">
|
||||
Invalid tag name
|
||||
</div>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: '#ffffff',
|
||||
iconBgColor: '#dd0000',
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]')
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + 'k'
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false
|
||||
this.favicon.href = this.iconPath
|
||||
return
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize
|
||||
// Draw badge text
|
||||
let textPaddingX = 7
|
||||
let textPaddingY = 3
|
||||
|
||||
let strlen = this.count.toString().length
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4
|
||||
fontSize = strlen > 3 ? 30 : 36
|
||||
}
|
||||
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
|
||||
let ctx = canvas.getContext('2d')
|
||||
|
||||
// Draw base icon
|
||||
let icon = new Image()
|
||||
icon.src = this.iconPath
|
||||
await icon.decode()
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64)
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'top'
|
||||
let textMetrics = ctx.measureText(this.count)
|
||||
|
||||
// Draw badge
|
||||
let paddingX = 7
|
||||
let paddingY = 4
|
||||
let cornerRadius = 8
|
||||
|
||||
let width = textMetrics.width + paddingX * 2
|
||||
let height = fontSize + paddingY * 2
|
||||
let x = canvas.width - width
|
||||
let y = canvas.height - height - 1
|
||||
|
||||
ctx.fillStyle = this.iconBgColor
|
||||
ctx.roundRect(x, y, width, height, cornerRadius)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = this.iconTextColor
|
||||
ctx.fillText(
|
||||
this.count,
|
||||
canvas.width - textPaddingX,
|
||||
canvas.height - fontSize - textPaddingY
|
||||
)
|
||||
|
||||
this.iconProcessing = false
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
@@ -1,135 +1,142 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import dayjs from 'dayjs'
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import dayjs from "dayjs";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
CommonMixins
|
||||
],
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
|
||||
// use different name to `loading` as that is already in use in CommonMixins
|
||||
loadingMessages: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
const relativeTime = require("dayjs/plugin/relativeTime");
|
||||
dayjs.extend(relativeTime);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshUI()
|
||||
this.refreshUI();
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
this.$forceUpdate();
|
||||
this.refreshUI();
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
const d = new Date(message.Created);
|
||||
return dayjs(d).fromNow();
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
if (message.To && message.To.length > 0) {
|
||||
return message.To[0].Address;
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
return "[ Undisclosed recipients ]";
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
toggleSelected(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
if (this.isSelected(id)) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
} else {
|
||||
mailbox.selected.push(id)
|
||||
mailbox.selected.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
selectRange(e, id) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
let selecting = false
|
||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
||||
if (lastSelected == id) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
return
|
||||
let selecting = false;
|
||||
const lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];
|
||||
if (lastSelected === id) {
|
||||
mailbox.selected = mailbox.selected.filter((ele) => {
|
||||
return ele !== id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastSelected === false) {
|
||||
mailbox.selected.push(id)
|
||||
return
|
||||
mailbox.selected.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let d of mailbox.messages) {
|
||||
for (const d of mailbox.messages) {
|
||||
if (selecting) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
if (d.ID == lastSelected || d.ID == id) {
|
||||
if (d.ID === lastSelected || d.ID === id) {
|
||||
// reached backwards select
|
||||
break
|
||||
break;
|
||||
}
|
||||
} else if (d.ID == id || d.ID == lastSelected) {
|
||||
} else if (d.ID === id || d.ID === lastSelected) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
mailbox.selected.push(d.ID);
|
||||
}
|
||||
selecting = true
|
||||
selecting = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
|
||||
<RouterLink
|
||||
v-for="message in mailbox.messages"
|
||||
:id="message.ID"
|
||||
:key="'message_' + message.ID"
|
||||
:to="'/view/' + message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)">
|
||||
:class="[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']"
|
||||
@click.meta="toggleSelected($event, message.ID)"
|
||||
@click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)"
|
||||
>
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip h6 me-1"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div v-if="message.From" class="overflow-x-hidden">
|
||||
@@ -142,30 +149,37 @@ export default {
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="text-truncate text-muted small privacy">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
<span v-if="message.To && message.To.length > 1"> [+{{ message.To.length - 1 }}] </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div class="subject text-truncate text-spaces-nowrap">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
<b>{{ message.Subject !== "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div v-if="message.Snippet != ''" class="small text-muted text-truncate">
|
||||
<div v-if="message.Snippet !== ''" class="small text-muted text-truncate">
|
||||
{{ message.Snippet }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
v-on:click="pagination.start = 0"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
<RouterLink
|
||||
v-for="t in message.Tags"
|
||||
:key="t"
|
||||
class="badge me-1"
|
||||
:to="toTagUrl(t)"
|
||||
:style="
|
||||
mailbox.showTagColors
|
||||
? { backgroundColor: colorHash(t) }
|
||||
: { backgroundColor: '#6c757d' }
|
||||
"
|
||||
:title="'Filter messages tagged with ' + t"
|
||||
@click="pagination.start = 0"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
<i v-if="message.Attachments" class="bi bi-paperclip float-start h6"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||
@@ -176,10 +190,10 @@ export default {
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<span v-if="loadingMessages > 0" class="text-secondary">
|
||||
Loading messages...
|
||||
</span>
|
||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<span v-if="loadingMessages > 0" class="text-muted"> Loading messages... </span>
|
||||
<template v-else-if="getSearch()"
|
||||
>No results for <code>{{ getSearch() }}</code></template
|
||||
>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,154 +1,193 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from "./AjaxLoader.vue"
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadInbox() {
|
||||
const paginationParams = this.getPaginationParams()
|
||||
const reload = paginationParams?.start ? false : true
|
||||
const paginationParams = this.getPaginationParams();
|
||||
const reload = !paginationParams?.start;
|
||||
|
||||
this.$router.push('/')
|
||||
this.$router.push("/");
|
||||
if (reload) {
|
||||
// already on first page, reload messages
|
||||
this.loadMessages()
|
||||
this.loadMessages();
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
this.put(this.resolve(`/api/v1/messages`), { read: true }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
pagination.start = 0;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<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 v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
<button class="list-group-item list-group-item-action active" @click="reloadInbox">
|
||||
<i v-if="mailbox.connected" class="bi bi-envelope-fill me-1"></i>
|
||||
<i v-else class="bi bi-arrow-clockwise me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.unread" @click="markAllRead">
|
||||
<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.unread">
|
||||
<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>
|
||||
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total" @click="deleteAllMessages">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<!-- checking if MessageRelay is defined prevents UI flicker while loading -->
|
||||
<template v-if="mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton">
|
||||
<button
|
||||
v-if="mailbox.skipConfirmations"
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total"
|
||||
@click="deleteAllMessages"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="list-group-item list-group-item-action"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal"
|
||||
:disabled="!mailbox.total"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
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 messages as read?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">Mark all messages 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.unread) }}
|
||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
||||
This will mark {{ formatNumber(mailbox.unread) }} message<span v-if="mailbox.unread > 1"
|
||||
>s</span
|
||||
>
|
||||
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>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }}
|
||||
message<span v-if="mailbox.total > 1">s</span>.
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }} message<span
|
||||
v-if="mailbox.total > 1"
|
||||
>s</span
|
||||
>.
|
||||
</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-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
120
server/ui-src/components/NavPagination.vue
Normal file
120
server/ui-src/components/NavPagination.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { limitOptions, pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0;
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > pagination.start + mailbox.messages.length;
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10);
|
||||
if (t > this.total) {
|
||||
t = this.total;
|
||||
}
|
||||
|
||||
return t;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit;
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
}
|
||||
pagination.start = s;
|
||||
this.updateQueryParams();
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path;
|
||||
const p = {
|
||||
...this.$route.query,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString();
|
||||
} else {
|
||||
delete p.start;
|
||||
}
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
} else {
|
||||
delete p.limit;
|
||||
}
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push(path + "?" + params.toString());
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<select
|
||||
v-model="pagination.limit"
|
||||
class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0"
|
||||
@change="changeLimit"
|
||||
>
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-light ms-2 me-1"
|
||||
:disabled="!canPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'"
|
||||
@click="viewPrev"
|
||||
>
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-light"
|
||||
:disabled="!canNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'"
|
||||
@click="viewNext"
|
||||
>
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,59 +1,80 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import NavSelected from "../components/NavSelected.vue";
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
this.hideNav(); // hide mobile menu
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
deleteAllMessages() {
|
||||
const s = this.getSearch()
|
||||
const s = this.getSearch();
|
||||
if (!s) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
this.delete(uri, false, (response) => {
|
||||
this.$router.push('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
<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 v-if="mailbox.uiConfig.Label" class="text-center badge text-bg-primary py-2 my-2 w-100">
|
||||
<div class="text-truncate fw-normal" style="line-height: 1rem">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,47 +83,121 @@ export default {
|
||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
<span
|
||||
v-if="mailbox.unread"
|
||||
class="badge rounded-pill ms-1 float-end text-bg-secondary"
|
||||
title="Unread messages"
|
||||
>
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
<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="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<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.count"
|
||||
@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.count"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
<NavSelected @load-messages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="MarkAllReadModal"
|
||||
class="modal fade"
|
||||
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="DeleteAllModalLabel">Delete all messages matching search?</h5>
|
||||
<h5 id="MarkAllReadModalLabel" class="modal-title">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 permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span> matching
|
||||
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" @click="markAllRead">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="DeleteAllModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="DeleteAllModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="DeleteAllModalLabel" class="modal-title">Delete all messages matching search?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }} message<span
|
||||
v-if="mailbox.count > 1"
|
||||
>s</span
|
||||
>
|
||||
matching
|
||||
<code>{{ getSearch() }}</code>
|
||||
</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-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" @click="deleteAllMessages">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,124 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import AjaxLoader from "./AjaxLoader.vue";
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
methods: {
|
||||
loadMessages() {
|
||||
this.$emit("loadMessages");
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
// mark selected messages as read
|
||||
markSelectedRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
isSelected(id) {
|
||||
return mailbox.selected.indexOf(id) !== -1;
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
this.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages() {
|
||||
let ids = []
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
||||
if (!ids.length) {
|
||||
return false
|
||||
}
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages() {
|
||||
let ids = [];
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected));
|
||||
if (!ids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.loadMessages()
|
||||
})
|
||||
},
|
||||
this.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, (response) => {
|
||||
window.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (const i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
// test of any selected emails are read
|
||||
selectedHasRead() {
|
||||
if (!mailbox.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (const i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||
v-on:click="markSelectedRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||
v-on:click="markSelectedUnread">
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasUnread()"
|
||||
@click="markSelectedRead"
|
||||
>
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
:disabled="!selectedHasRead()"
|
||||
@click="markSelectedUnread"
|
||||
>
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" @click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" @click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { mailbox } from "../stores/mailbox";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
@@ -10,79 +10,77 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// test whether a tag is currently being searched for (in the URL)
|
||||
inSearch(tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const query = urlParams.get("q");
|
||||
if (!query) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
const re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, "i");
|
||||
return query.match(re);
|
||||
},
|
||||
|
||||
// toggle a tag search in the search URL, add or remove it accordingly
|
||||
toggleTag(e, tag) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let query = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let query = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
|
||||
let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i')
|
||||
const re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, "i");
|
||||
|
||||
if (query.match(re)) {
|
||||
// remove is exists
|
||||
query = query.replace(re, '$1$4')
|
||||
query = query.replace(re, "$1$4");
|
||||
} else {
|
||||
// add to query
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
tag = `"${tag}"`;
|
||||
}
|
||||
query = query + " tag:" + tag
|
||||
query = query + " tag:" + tag;
|
||||
}
|
||||
|
||||
query = query.trim()
|
||||
query = query.trim();
|
||||
|
||||
if (query == '') {
|
||||
this.$router.push('/')
|
||||
if (query === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
start: pagination.start.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
})
|
||||
this.$router.push('/search?' + params.toString())
|
||||
});
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
t = `"${t}"`;
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
q: "tag:" + t,
|
||||
};
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
const params = new URLSearchParams(p);
|
||||
return "/search?" + params.toString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Tags</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
|
||||
@@ -99,12 +97,20 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
|
||||
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
|
||||
<RouterLink
|
||||
v-for="tag in mailbox.tags"
|
||||
:key="tag"
|
||||
:to="toTagUrl(tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
class="list-group-item list-group-item-action small px-2"
|
||||
:class="inSearch(tag) ? 'active' : ''"
|
||||
@click.exact="hideNav"
|
||||
@click="pagination.start = 0"
|
||||
@click.meta="toggleTag($event, tag)"
|
||||
@click.ctrl="toggleTag($event, tag)"
|
||||
>
|
||||
<i v-if="inSearch(tag)" class="bi bi-tag-fill"></i>
|
||||
<i v-else class="bi bi-tag"></i>
|
||||
{{ tag }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { Toast } from 'bootstrap'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const d = document.getElementById('app')
|
||||
if (d) {
|
||||
this.version = d.dataset.version
|
||||
}
|
||||
|
||||
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
||||
|
||||
this.socketBreakReset()
|
||||
this.connect()
|
||||
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect() {
|
||||
const ws = new WebSocket(this.socketURI)
|
||||
ws.onmessage = (e) => {
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(e.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
this.eventBus.emit("new", response.Data)
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!this.pauseNotifications) {
|
||||
this.pauseNotifications = true
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
||||
this.browserNotify("New mail from: " + from, response.Data.Subject)
|
||||
this.setMessageToast(response.Data)
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
|
||||
}
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
mailbox.unread = response.Data.Unread
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (this.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data)
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data)
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
mailbox.connected = true
|
||||
this.socketLastConnection = Date.now()
|
||||
if (this.reconnectRefresh) {
|
||||
this.reconnectRefresh = false
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (this.socketLastConnection == 0) {
|
||||
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
|
||||
console.log('Unable to connect to websocket, disabling websocket support')
|
||||
return
|
||||
}
|
||||
|
||||
if (mailbox.connected) {
|
||||
// count disconnections
|
||||
this.socketBreaks++
|
||||
}
|
||||
|
||||
// set disconnected state
|
||||
mailbox.connected = false
|
||||
|
||||
if (this.socketBreaks > 3) {
|
||||
// give up after > 3 successful socket connections & disconnections within a 15 second window,
|
||||
// something is not working right on their end, see issue #319
|
||||
console.log('Unstable websocket connection, disabling websocket support')
|
||||
return
|
||||
}
|
||||
if (Date.now() - this.socketLastConnection > 5000) {
|
||||
// only refresh mailbox if the last successful connection was broken for > 5 seconds
|
||||
this.reconnectRefresh = true
|
||||
} else {
|
||||
this.reconnectRefresh = false
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect() // reconnect
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0
|
||||
this.socketBreakReset()
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let options = {
|
||||
body: message,
|
||||
icon: this.resolve('/notification.png')
|
||||
}
|
||||
new Notification(title, options)
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast(m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toastMessage = m
|
||||
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
el.addEventListener('hidden.bs.toast', () => {
|
||||
this.toastMessage = false
|
||||
})
|
||||
|
||||
Toast.getOrCreateInstance(el).show()
|
||||
}
|
||||
},
|
||||
|
||||
closeToast() {
|
||||
const el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast">
|
||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else>
|
||||
[ no subject ]
|
||||
</template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,107 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { limitOptions, pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: Number,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
limitOptions,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev() {
|
||||
return pagination.start > 0
|
||||
},
|
||||
|
||||
canNext() {
|
||||
return this.total > (pagination.start + mailbox.messages.length)
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages() {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
||||
if (t > this.total) {
|
||||
t = this.total
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit() {
|
||||
pagination.start = 0
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewNext() {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
viewPrev() {
|
||||
let s = pagination.start - pagination.limit
|
||||
if (s < 0) {
|
||||
s = 0
|
||||
}
|
||||
pagination.start = s
|
||||
this.updateQueryParams()
|
||||
},
|
||||
|
||||
updateQueryParams() {
|
||||
const path = this.$route.path
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
} else {
|
||||
delete p.start
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
} else {
|
||||
delete p.limit
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push(path + '?' + params.toString())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0">
|
||||
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,78 +1,84 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import CommonMixins from "../mixins/CommonMixins";
|
||||
import { pagination } from "../stores/pagination";
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
emits: ["loadMessages"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL()
|
||||
search: "",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
this.searchFromURL()
|
||||
}
|
||||
this.searchFromURL();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL();
|
||||
},
|
||||
|
||||
methods: {
|
||||
searchFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
this.search = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.search = urlParams.get("q") ? urlParams.get("q") : "";
|
||||
},
|
||||
|
||||
doSearch(e) {
|
||||
pagination.start = 0
|
||||
if (this.search == '') {
|
||||
this.$router.push('/')
|
||||
pagination.start = 0;
|
||||
if (this.search === "") {
|
||||
this.$router.push("/");
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const curr = urlParams.get('q')
|
||||
if (curr && curr == this.search) {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const curr = urlParams.get("q");
|
||||
if (curr && curr === this.search) {
|
||||
pagination.start = 0;
|
||||
this.$emit("loadMessages");
|
||||
}
|
||||
const p = {
|
||||
q: this.search
|
||||
}
|
||||
q: this.search,
|
||||
};
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
p.start = pagination.start.toString();
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
if (pagination.limit !== pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/search?' + params.toString())
|
||||
const params = new URLSearchParams(p);
|
||||
this.$router.push("/search?" + params.toString());
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
resetSearch() {
|
||||
this.search = ''
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
this.search = "";
|
||||
this.$router.push("/");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-on:submit="doSearch">
|
||||
<form @submit="doSearch">
|
||||
<div class="input-group flex-nowrap">
|
||||
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||
placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
<input
|
||||
v-model.trim="search"
|
||||
type="text"
|
||||
class="form-control border-0"
|
||||
aria-label="Search"
|
||||
placeholder="Search mailbox"
|
||||
/>
|
||||
<span v-if="search != ''" class="btn btn-link position-absolute end-0 text-muted" @click="resetSearch"
|
||||
><i class="bi bi-x-circle"></i
|
||||
></span>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import timezones from 'timezones-list'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(v) {
|
||||
if (v == 'auto') {
|
||||
localStorage.removeItem('theme')
|
||||
} else {
|
||||
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() {
|
||||
this.setTheme()
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
|
||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme() {
|
||||
if (
|
||||
this.theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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">
|
||||
<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 <code>Delete all</code> & <code>Mark all read</code> confirmation
|
||||
dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,221 +1,234 @@
|
||||
<script>
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import { VcDonut } from "vue-css-donut-chart";
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
"windows": "Windows",
|
||||
windows: "Windows",
|
||||
"windows-mail": "Windows Mail",
|
||||
"outlook-com": "Outlook.com",
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
"android": "Android",
|
||||
macos: "macOS",
|
||||
ios: "iOS",
|
||||
android: "Android",
|
||||
"desktop-webmail": "Desktop Webmail",
|
||||
"mobile-webmail": "Mobile Webmail",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
summary() {
|
||||
if (!this.check) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = {
|
||||
const result = {
|
||||
Warnings: [],
|
||||
Total: {
|
||||
Nodes: this.check.Total.Nodes
|
||||
}
|
||||
}
|
||||
Nodes: this.check.Total.Nodes,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
||||
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
|
||||
const o = JSON.parse(JSON.stringify(this.check.Warnings[i]));
|
||||
|
||||
// for <script> test
|
||||
if (o.Results.length == 0) {
|
||||
result.Warnings.push(o)
|
||||
continue
|
||||
if (o.Results.length === 0) {
|
||||
result.Warnings.push(o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// filter by enabled platforms
|
||||
let results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) != -1
|
||||
})
|
||||
const results = o.Results.filter((w) => {
|
||||
return this.platforms.indexOf(w.Platform) !== -1;
|
||||
});
|
||||
|
||||
if (results.length == 0) {
|
||||
continue
|
||||
if (results.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// recalculate the percentages
|
||||
let y = 0, p = 0, n = 0
|
||||
let y = 0;
|
||||
let p = 0;
|
||||
let n = 0;
|
||||
|
||||
results.forEach(function (r) {
|
||||
if (r.Support == "yes") {
|
||||
y++
|
||||
} else if (r.Support == "partial") {
|
||||
p++
|
||||
results.forEach((r) => {
|
||||
if (r.Support === "yes") {
|
||||
y++;
|
||||
} else if (r.Support === "partial") {
|
||||
p++;
|
||||
} else {
|
||||
n++
|
||||
n++;
|
||||
}
|
||||
})
|
||||
let total = y + p + n
|
||||
o.Results = results
|
||||
});
|
||||
const total = y + p + n;
|
||||
o.Results = results;
|
||||
o.Score = {
|
||||
Found: o.Score.Found,
|
||||
Supported: y / total * 100,
|
||||
Partial: p / total * 100,
|
||||
Unsupported: n / total * 100
|
||||
}
|
||||
Supported: (y / total) * 100,
|
||||
Partial: (p / total) * 100,
|
||||
Unsupported: (n / total) * 100,
|
||||
};
|
||||
|
||||
result.Warnings.push(o)
|
||||
result.Warnings.push(o);
|
||||
}
|
||||
|
||||
let maxPartial = 0, maxUnsupported = 0
|
||||
let maxPartial = 0;
|
||||
let maxUnsupported = 0;
|
||||
result.Warnings.forEach((w) => {
|
||||
let scoreWeight = 1
|
||||
let scoreWeight = 1;
|
||||
if (w.Score.Found < result.Total.Nodes) {
|
||||
// each error is weighted based on the number of occurrences vs: the total message nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes;
|
||||
}
|
||||
|
||||
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
|
||||
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||
// off the calculation as these dominate.
|
||||
if (this.isPseudoClassOrAtRule(w.Title)) {
|
||||
scoreWeight = 0.05
|
||||
w.PseudoClassOrAtRule = true
|
||||
scoreWeight = 0.05;
|
||||
w.PseudoClassOrAtRule = true;
|
||||
}
|
||||
|
||||
let scorePartial = w.Score.Partial * scoreWeight
|
||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
||||
const scorePartial = w.Score.Partial * scoreWeight;
|
||||
const scoreUnsupported = w.Score.Unsupported * scoreWeight;
|
||||
if (scorePartial > maxPartial) {
|
||||
maxPartial = scorePartial
|
||||
maxPartial = scorePartial;
|
||||
}
|
||||
if (scoreUnsupported > maxUnsupported) {
|
||||
maxUnsupported = scoreUnsupported
|
||||
maxUnsupported = scoreUnsupported;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// sort warnings by final score
|
||||
result.Warnings.sort((a, b) => {
|
||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
||||
let aWeight =
|
||||
a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes;
|
||||
let bWeight =
|
||||
b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes;
|
||||
|
||||
if (this.isPseudoClassOrAtRule(a.Title)) {
|
||||
aWeight = 0.05
|
||||
aWeight = 0.05;
|
||||
}
|
||||
|
||||
if (this.isPseudoClassOrAtRule(b.Title)) {
|
||||
bWeight = 0.05
|
||||
bWeight = 0.05;
|
||||
}
|
||||
|
||||
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
})
|
||||
return (
|
||||
(a.Score.Unsupported + a.Score.Partial) * aWeight <
|
||||
(b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
);
|
||||
});
|
||||
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported
|
||||
result.Total.Partial = maxPartial
|
||||
result.Total.Unsupported = maxUnsupported
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported;
|
||||
result.Total.Partial = maxPartial;
|
||||
result.Total.Unsupported = maxUnsupported;
|
||||
|
||||
this.$emit('setHtmlScore', result.Total.Supported)
|
||||
this.$emit("setHtmlScore", result.Total.Supported);
|
||||
|
||||
return result
|
||||
return result;
|
||||
},
|
||||
|
||||
graphSections() {
|
||||
let s = Math.round(this.summary.Total.Supported)
|
||||
let p = Math.round(this.summary.Total.Partial)
|
||||
let u = 100 - s - p
|
||||
const s = Math.round(this.summary.Total.Supported);
|
||||
const p = Math.round(this.summary.Total.Partial);
|
||||
const u = 100 - s - p;
|
||||
return [
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
||||
label: this.round2dm(this.summary.Total.Supported) + "% supported",
|
||||
value: s,
|
||||
color: '#198754'
|
||||
color: "#198754",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
|
||||
label: this.round2dm(this.summary.Total.Partial) + "% partially supported",
|
||||
value: p,
|
||||
color: '#ffc107'
|
||||
color: "#ffc107",
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + "% not supported",
|
||||
value: u,
|
||||
color: '#dc3545'
|
||||
}
|
||||
]
|
||||
color: "#dc3545",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// colors depend on both varying unsupported & partially unsupported percentages
|
||||
scoreColor() {
|
||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||
this.$emit('setBadgeStyle', 'bg-success')
|
||||
return 'text-success'
|
||||
this.$emit("setBadgeStyle", "bg-success");
|
||||
return "text-success";
|
||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
||||
return 'text-warning'
|
||||
this.$emit("setBadgeStyle", "bg-warning text-primary");
|
||||
return "text-warning";
|
||||
}
|
||||
|
||||
this.$emit('setBadgeStyle', 'bg-danger')
|
||||
return 'text-danger'
|
||||
}
|
||||
this.$emit("setBadgeStyle", "bg-danger");
|
||||
return "text-danger";
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setHtmlScore', false)
|
||||
this.doCheck()
|
||||
this.$emit("setHtmlScore", false);
|
||||
this.doCheck();
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
localStorage.setItem("html-check-platforms", JSON.stringify(v));
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConfig();
|
||||
this.doCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.check = false;
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
return
|
||||
if (this.message.HTML === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
|
||||
axios
|
||||
.get(this.resolve("/api/v1/message/" + this.message.ID + "/html-check"), null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
// set tooltips
|
||||
window.setTimeout(() => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
}, 500)
|
||||
[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
|
||||
}, 500);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@@ -223,68 +236,72 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
loadConfig() {
|
||||
let platforms = localStorage.getItem('html-check-platforms')
|
||||
const platforms = localStorage.getItem("html-check-platforms");
|
||||
if (platforms) {
|
||||
try {
|
||||
this.platforms = JSON.parse(platforms)
|
||||
} catch (e) {
|
||||
}
|
||||
this.platforms = JSON.parse(platforms);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// set all options
|
||||
if (this.platforms.length == 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms)
|
||||
if (this.platforms.length === 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms);
|
||||
}
|
||||
},
|
||||
|
||||
// return a platform's families (email clients)
|
||||
families(k) {
|
||||
if (this.check.Platforms[k]) {
|
||||
return this.check.Platforms[k]
|
||||
return this.check.Platforms[k];
|
||||
}
|
||||
|
||||
return []
|
||||
return [];
|
||||
},
|
||||
|
||||
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||
isPseudoClassOrAtRule(t) {
|
||||
return t.match(/^(:|@)/)
|
||||
return t.match(/^(:|@)/);
|
||||
},
|
||||
|
||||
round(v) {
|
||||
return Math.round(v)
|
||||
return Math.round(v);
|
||||
},
|
||||
|
||||
round2dm(v) {
|
||||
return Math.round(v * 100) / 100
|
||||
return Math.round(v * 100) / 100;
|
||||
},
|
||||
|
||||
scrollToWarnings() {
|
||||
if (!this.$refs.warnings) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
sanitizeHTML(html) {
|
||||
return DOMPurify.sanitize(html);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -299,39 +316,50 @@ export default {
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
||||
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
|
||||
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
|
||||
<vc-donut
|
||||
:sections="graphSections"
|
||||
background="var(--bs-body-bg)"
|
||||
:size="180"
|
||||
unit="px"
|
||||
:thickness="20"
|
||||
has-legend
|
||||
legend-placement="bottom"
|
||||
:total="100"
|
||||
:start-angle="0"
|
||||
:auto-adjust-text-size="true"
|
||||
@section-click="scrollToWarnings"
|
||||
>
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ round2dm(summary.Total.Supported) }}%
|
||||
</h2>
|
||||
<div class="text-body">
|
||||
support
|
||||
</div>
|
||||
<div class="text-body">support</div>
|
||||
<template #legend>
|
||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-success"></i>
|
||||
{{ round2dm(summary.Total.Supported) }}% supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-warning"></i>
|
||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-danger"></i>
|
||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-secondary">
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
<p class="small text-muted">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>
|
||||
</template>
|
||||
</vc-donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@@ -339,12 +367,24 @@ export default {
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
||||
<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(', ')"
|
||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
||||
<div v-for="(p, k) in allPlatforms" :key="'check_' + k" class="form-check form-switch">
|
||||
<input
|
||||
:id="'Check_' + k"
|
||||
v-model="platforms"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:value="k"
|
||||
:aria-label="p"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-muted'"
|
||||
:title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip"
|
||||
:data-bs-title="families(k).join(', ')"
|
||||
>
|
||||
{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -356,45 +396,72 @@ export default {
|
||||
<h4 ref="warnings" class="h5 mt-4">
|
||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||
</h4>
|
||||
<div class="accordion" id="warnings">
|
||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
||||
<div id="warnings" class="accordion">
|
||||
<div v-for="(warning, i) in summary.Warnings" :key="'warning_' + i" class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug"
|
||||
aria-expanded="false"
|
||||
:aria-controls="warning.Slug"
|
||||
>
|
||||
<div class="row w-100 w-lg-75">
|
||||
<div class="col-sm">
|
||||
{{ warning.Title }}
|
||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||
{{ warning.Category }}
|
||||
</span>
|
||||
<span class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected">
|
||||
<span
|
||||
class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected"
|
||||
>
|
||||
x {{ warning.Score.Found }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm mt-2 mt-sm-0">
|
||||
<div class="progress-stacked">
|
||||
<div class="progress" role="progressbar" aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Supported + '%' }"
|
||||
title="Supported"
|
||||
>
|
||||
<div class="progress-bar bg-success">
|
||||
{{ round(warning.Score.Supported) + '%' }}
|
||||
{{ round(warning.Score.Supported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }"
|
||||
title="Partial support"
|
||||
>
|
||||
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||
{{ round(warning.Score.Partial) + '%' }}
|
||||
{{ round(warning.Score.Partial) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
|
||||
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported">
|
||||
<div
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Unsupported + '%' }"
|
||||
title="Not supported"
|
||||
>
|
||||
<div class="progress-bar bg-danger">
|
||||
{{ round(warning.Score.Unsupported) + '%' }}
|
||||
{{ round(warning.Score.Unsupported) + "%" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,28 +471,45 @@ export default {
|
||||
</h2>
|
||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||
<div class="accordion-body">
|
||||
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
|
||||
<p v-if="warning.Description !== '' || warning.PseudoClassOrAtRule">
|
||||
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
||||
v-else>ies</template> in the CSS
|
||||
styles, but unable to test if used or not.
|
||||
<template v-if="warning.Score.Found === 1">property</template>
|
||||
<template v-else>properties</template>
|
||||
in the CSS styles, but unable to test if used or not.
|
||||
</span>
|
||||
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
v-if="warning.Description !== ''"
|
||||
class="me-2"
|
||||
v-html="sanitizeHTML(warning.Description)"
|
||||
></span>
|
||||
<!-- -eslint-disable vue/no-v-html -->
|
||||
</p>
|
||||
|
||||
<template v-if="warning.Results.length">
|
||||
<h3 class="h6">Clients with partial or no support:</h3>
|
||||
<p>
|
||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
||||
<i class="bi bi-circle-fill"
|
||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
||||
{{ warning.Name }}
|
||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
||||
title="See notes">
|
||||
{{ warning.NoteNumber }}
|
||||
<small
|
||||
v-for="(warningRes, wi) in warning.Results"
|
||||
:key="'warning_results_' + wi"
|
||||
class="text-nowrap d-inline-block me-4"
|
||||
>
|
||||
<i
|
||||
class="bi bi-circle-fill"
|
||||
:class="warningRes.Support === 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="
|
||||
warningRes.Support === 'no' ? 'Not supported' : 'Partially supported'
|
||||
"
|
||||
></i>
|
||||
{{ warningRes.Name }}
|
||||
<span
|
||||
v-if="warningRes.NoteNumber !== ''"
|
||||
class="badge text-bg-secondary"
|
||||
title="See notes"
|
||||
>
|
||||
{{ warningRes.NoteNumber }}
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
@@ -433,17 +517,21 @@ export default {
|
||||
|
||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||
<h3 class="h6">Notes:</h3>
|
||||
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
|
||||
<div
|
||||
v-for="(n, ni) in warning.NotesByNumber"
|
||||
:key="'warning_notes' + ni"
|
||||
class="small row my-2"
|
||||
>
|
||||
<div class="col-auto pe-0">
|
||||
<span class="badge text-bg-secondary">
|
||||
{{ i }}
|
||||
{{ ni }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col" v-html="n"></div>
|
||||
<div class="col" v-html="sanitizeHTML(n)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small mt-3 mb-0" v-if="warning.URL">
|
||||
<p v-if="warning.URL" class="small mt-3 mb-0">
|
||||
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -451,31 +539,45 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-secondary 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 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>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutHTMLCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
|
||||
<h1 id="AboutHTMLCheckResultsLabel" class="modal-title fs-5">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="HTMLCheckAboutAccordion">
|
||||
<div id="HTMLCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is HTML check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
The support for HTML/CSS messages varies greatly across email clients. HTML
|
||||
check attempts to calculate the overall support for your email for all selected
|
||||
@@ -485,13 +587,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
How does it work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Internally the original HTML message is run against
|
||||
@@ -504,10 +615,11 @@ export default {
|
||||
CSS support is very difficult to programmatically test, especially if a
|
||||
message contains CSS style blocks or is linked to remote stylesheets. Remote
|
||||
stylesheets are, unless blocked via
|
||||
<code>--block-remote-css-and-fonts</code>,
|
||||
downloaded and injected into the message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer"
|
||||
target="_blank">inlined</a>
|
||||
<code>--block-remote-css-and-fonts</code>, downloaded and injected into the
|
||||
message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer" target="_blank"
|
||||
>inlined</a
|
||||
>
|
||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||
</p>
|
||||
<p>
|
||||
@@ -528,13 +640,22 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Is the final score accurate?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
There are many ways to define "accurate", and how one should calculate the
|
||||
@@ -578,13 +699,22 @@ export default {
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What about invalid HTML?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
HTML check does not detect if the original HTML is valid. In order to detect
|
||||
applied styles to every node, the HTML email is run through a parser which is
|
||||
@@ -592,7 +722,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
headers: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
|
||||
this.get(uri, false, (response) => {
|
||||
this.headers = response.data
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="headers" class="small">
|
||||
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +1,19 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import axios from "axios";
|
||||
import commonMixins from "../../mixins/CommonMixins";
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
|
||||
props: {
|
||||
message: Object,
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["setLinkErrors"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
@@ -19,116 +22,116 @@ export default {
|
||||
check: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem('LinkCheckAutoScan')
|
||||
this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects')
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true
|
||||
if (this.autoScan) {
|
||||
this.doCheck()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckAutoScan', true)
|
||||
if (!this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckAutoScan')
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem('LinkCheckFollowRedirects', true)
|
||||
} else {
|
||||
localStorage.removeItem('LinkCheckFollowRedirects')
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
groupedStatuses() {
|
||||
let results = {}
|
||||
const results = {};
|
||||
|
||||
if (!this.check) {
|
||||
return results
|
||||
return results;
|
||||
}
|
||||
|
||||
// group by status
|
||||
this.check.Links.forEach(function (r) {
|
||||
this.check.Links.forEach((r) => {
|
||||
if (!results[r.StatusCode]) {
|
||||
let css = ""
|
||||
let css = "";
|
||||
if (r.StatusCode >= 400 || r.StatusCode === 0) {
|
||||
css = "text-danger"
|
||||
css = "text-danger";
|
||||
} else if (r.StatusCode >= 300) {
|
||||
css = "text-info"
|
||||
css = "text-info";
|
||||
}
|
||||
|
||||
if (r.StatusCode === 0) {
|
||||
r.Status = 'Cannot connect to server'
|
||||
r.Status = "Cannot connect to server";
|
||||
}
|
||||
results[r.StatusCode] = {
|
||||
StatusCode: r.StatusCode,
|
||||
Status: r.Status,
|
||||
Class: css,
|
||||
URLS: []
|
||||
}
|
||||
URLS: [],
|
||||
};
|
||||
}
|
||||
results[r.StatusCode].URLS.push(r.URL)
|
||||
})
|
||||
results[r.StatusCode].URLS.push(r.URL);
|
||||
});
|
||||
|
||||
let newArr = []
|
||||
const newArr = [];
|
||||
|
||||
for (const i in results) {
|
||||
newArr.push(results[i])
|
||||
newArr.push(results[i]);
|
||||
}
|
||||
|
||||
// sort statuses
|
||||
let sorted = newArr.sort((a, b) => {
|
||||
const sorted = newArr.sort((a, b) => {
|
||||
if (a.StatusCode === 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return a.StatusCode < b.StatusCode
|
||||
})
|
||||
return a.StatusCode < b.StatusCode;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
},
|
||||
},
|
||||
|
||||
return sorted
|
||||
watch: {
|
||||
autoScan(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckAutoScan", true);
|
||||
if (!this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckAutoScan");
|
||||
}
|
||||
},
|
||||
followRedirects(v) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
if (v) {
|
||||
localStorage.setItem("LinkCheckFollowRedirects", true);
|
||||
} else {
|
||||
localStorage.removeItem("LinkCheckFollowRedirects");
|
||||
}
|
||||
if (this.check) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.autoScan = localStorage.getItem("LinkCheckAutoScan");
|
||||
this.followRedirects = localStorage.getItem("LinkCheckFollowRedirects");
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loaded = true;
|
||||
if (this.autoScan) {
|
||||
this.doCheck();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck() {
|
||||
this.check = false
|
||||
this.loading = true
|
||||
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
|
||||
this.check = false;
|
||||
this.loading = true;
|
||||
let uri = this.resolve("/api/v1/message/" + this.message.ID + "/link-check");
|
||||
if (this.followRedirects) {
|
||||
uri += '?follow=true'
|
||||
uri += "?follow=true";
|
||||
}
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(uri, null)
|
||||
axios
|
||||
.get(uri, null)
|
||||
.then((result) => {
|
||||
this.check = result.data
|
||||
this.error = false
|
||||
this.check = result.data;
|
||||
this.error = false;
|
||||
|
||||
this.$emit('setLinkErrors', result.data.Errors)
|
||||
this.$emit("setLinkErrors", result.data.Errors);
|
||||
})
|
||||
.catch((error) => {
|
||||
// handle error
|
||||
@@ -136,27 +139,27 @@ export default {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
this.error = error.response.data.Error
|
||||
this.error = error.response.data.Error;
|
||||
} else {
|
||||
this.error = error.response.data
|
||||
this.error = error.response.data;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
this.error = 'Error sending data to the server. Please try again.'
|
||||
this.error = "Error sending data to the server. Please try again.";
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
this.error = error.message
|
||||
this.error = error.message;
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
// always run
|
||||
this.loading = false
|
||||
})
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -164,24 +167,24 @@ export default {
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">
|
||||
<template v-if="!check">
|
||||
Link check
|
||||
</template>
|
||||
<template v-if="!check"> Link check </template>
|
||||
<template v-else>
|
||||
<template v-if="check.Links.length">
|
||||
Scanned {{ formatNumber(check.Links.length) }}
|
||||
link<template v-if="check.Links.length != 1">s</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
No links detected
|
||||
Scanned {{ formatNumber(check.Links.length) }} link<template v-if="check.Links.length != 1"
|
||||
>s</template
|
||||
>
|
||||
</template>
|
||||
<template v-else> No links detected </template>
|
||||
</template>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#AboutLinkCheckResults"
|
||||
>
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
@@ -194,13 +197,13 @@ export default {
|
||||
</div>
|
||||
|
||||
<div v-if="!check">
|
||||
<p class="text-secondary">
|
||||
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 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>
|
||||
|
||||
<p class="text-center my-5">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading">
|
||||
<button v-if="!check" class="btn btn-primary btn-lg" :disabled="loading" @click="doCheck()">
|
||||
<template v-if="loading">
|
||||
Checking links
|
||||
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
|
||||
@@ -215,14 +218,14 @@ export default {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else v-for="s, k in groupedStatuses">
|
||||
<div v-for="(s, k) in groupedStatuses" v-else :key="k">
|
||||
<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">
|
||||
<li v-for="(u, i) in s.URLS" :key="'status' + i" class="list-group-item">
|
||||
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -235,22 +238,31 @@ export default {
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="LinkCheckOptions"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="LinkCheckOptionsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1>
|
||||
<h1 id="LinkCheckOptionsLabel" class="modal-title fs-5">Link check options</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
|
||||
id="LinkCheckFollowRedirectsSwitch">
|
||||
<input
|
||||
id="LinkCheckFollowRedirectsSwitch"
|
||||
v-model="followRedirects"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
|
||||
<template v-if="followRedirects">Following HTTP redirects</template>
|
||||
<template v-else>Not following HTTP redirects</template>
|
||||
@@ -259,8 +271,13 @@ export default {
|
||||
|
||||
<h6 class="mt-4">Automatic link checking</h6>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan"
|
||||
id="LinkCheckAutoCheckSwitch">
|
||||
<input
|
||||
id="LinkCheckAutoCheckSwitch"
|
||||
v-model="autoScan"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
|
||||
<template v-if="autoScan">Automatic link checking is enabled</template>
|
||||
<template v-else>Automatic link checking is disabled</template>
|
||||
@@ -270,7 +287,6 @@ export default {
|
||||
Only enable this if you understand the potential risks & consequences.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@@ -279,25 +295,39 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div
|
||||
id="AboutLinkCheckResults"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="AboutLinkCheckResultsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1>
|
||||
<h1 id="AboutLinkCheckResultsLabel" class="modal-title fs-5">About Link check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="accordion" id="LinkCheckAboutAccordion">
|
||||
<div id="LinkCheckAboutAccordion" class="accordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col1"
|
||||
aria-expanded="false"
|
||||
aria-controls="col1"
|
||||
>
|
||||
What is Link check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col1"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
Link check scans your message HTML and text for all unique links, images and linked
|
||||
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
|
||||
@@ -307,35 +337,52 @@ export default {
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col2"
|
||||
aria-expanded="false"
|
||||
aria-controls="col2"
|
||||
>
|
||||
What are "301" and "302" links?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col2"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
These are links that redirect you to another URL, for example newsletters
|
||||
often use redirect links to track user clicks.
|
||||
These are links that redirect you to another URL, for example newsletters often
|
||||
use redirect links to track user clicks.
|
||||
</p>
|
||||
<p>
|
||||
By default Link check will not follow these links, however you can turn this on
|
||||
via
|
||||
the settings and Link check will "follow" those redirects.
|
||||
via the settings and Link check will "follow" those redirects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col3"
|
||||
aria-expanded="false"
|
||||
aria-controls="col3"
|
||||
>
|
||||
Why are some links returning an error but work in my browser?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col3"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>This may be due to various reasons, for instance:</p>
|
||||
<ul>
|
||||
@@ -345,20 +392,29 @@ export default {
|
||||
The webserver is blocking requests that don't come from authenticated web
|
||||
browsers.
|
||||
</li>
|
||||
<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests. </li>
|
||||
<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
<button
|
||||
class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#col4"
|
||||
aria-expanded="false"
|
||||
aria-controls="col4"
|
||||
>
|
||||
What are the risks of running Link check automatically?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion">
|
||||
<div
|
||||
id="col4"
|
||||
class="accordion-collapse collapse"
|
||||
data-bs-parent="#LinkCheckAboutAccordion"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Depending on the type of messages you are testing, opening all links on all
|
||||
@@ -382,7 +438,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -1,643 +0,0 @@
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
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'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
SpamAssassin,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
canSaveTags: false, // prevent auto-saving tags on render
|
||||
availableTags: [],
|
||||
messageTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
linkCheckErrors: false,
|
||||
spamScore: false,
|
||||
spamScoreColor: false,
|
||||
showMobileButtons: false,
|
||||
showUnsubscribe: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
phone: 'width: 322px; height: 570px',
|
||||
tablet: 'width: 768px; height: 1024px',
|
||||
display: 'width: 100%; height: 100%',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
messageTags() {
|
||||
if (this.canSaveTags) {
|
||||
// save changes to tags
|
||||
this.saveTags()
|
||||
}
|
||||
},
|
||||
|
||||
scaleHTMLPreview(v) {
|
||||
if (v == 'display') {
|
||||
window.setTimeout(() => {
|
||||
this.resizeIFrames()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasAnyChecksEnabled() {
|
||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
||||
|| mailbox.showLinkCheck
|
||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||
},
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
// set target & rel on all links
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName != 'A' || (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#')) {
|
||||
return
|
||||
}
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
|
||||
node.setAttribute('xlink:show', '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
this.message.HTML,
|
||||
{
|
||||
WHOLE_DOCUMENT: true,
|
||||
SANITIZE_DOM: false,
|
||||
ADD_TAGS: [
|
||||
'link',
|
||||
'meta',
|
||||
'o:p',
|
||||
'style',
|
||||
],
|
||||
ADD_ATTR: [
|
||||
'bordercolor',
|
||||
'charset',
|
||||
'content',
|
||||
'hspace',
|
||||
'http-equiv',
|
||||
'itemprop',
|
||||
'itemscope',
|
||||
'itemtype',
|
||||
'link',
|
||||
'vertical-align',
|
||||
'vlink',
|
||||
'vspace',
|
||||
'xml:lang',
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
)
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.canSaveTags = false
|
||||
this.messageTags = this.message.Tags
|
||||
this.renderUI()
|
||||
|
||||
window.addEventListener("resize", this.resizeIFrames)
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab')
|
||||
headersTab.addEventListener('shown.bs.tab', (event) => {
|
||||
this.loadHeaders = true
|
||||
})
|
||||
|
||||
let rawTab = document.getElementById('nav-raw-tab')
|
||||
rawTab.addEventListener('shown.bs.tab', (event) => {
|
||||
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
|
||||
this.resizeIFrames()
|
||||
})
|
||||
|
||||
// manually refresh tags
|
||||
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
|
||||
this.availableTags = response.data
|
||||
this.$nextTick(() => {
|
||||
Tags.init('select[multiple]')
|
||||
// delay tag change detection to allow Tags to load
|
||||
window.setTimeout(() => {
|
||||
this.canSaveTags = true
|
||||
}, 200)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
isHTMLTabSelected() {
|
||||
this.showMobileButtons = this.$refs.navhtml
|
||||
&& this.$refs.navhtml.classList.contains('active')
|
||||
},
|
||||
|
||||
renderUI() {
|
||||
// activate the first non-disabled tab
|
||||
document.querySelector('#nav-tab button:not([disabled])').click()
|
||||
document.activeElement.blur() // blur focus
|
||||
document.getElementById('message-view').scrollTop = 0
|
||||
|
||||
this.isHTMLTabSelected()
|
||||
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
|
||||
listObj.addEventListener('shown.bs.tab', (event) => {
|
||||
this.isHTMLTabSelected()
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
|
||||
// delay 0.5s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
try {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^https?:\/\//i)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
this.resizeIFrames()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// html highlighting
|
||||
window.Prism = window.Prism || {}
|
||||
window.Prism.manual = true
|
||||
Prism.highlightAll()
|
||||
},
|
||||
|
||||
resizeIframe(el) {
|
||||
let i = el.target
|
||||
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
},
|
||||
|
||||
resizeIFrames() {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return
|
||||
}
|
||||
let h = document.getElementById('preview-html')
|
||||
if (h) {
|
||||
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// set the iframe body & text colors based on current theme
|
||||
initRawIframe(el) {
|
||||
let bodyStyles = window.getComputedStyle(document.body, null)
|
||||
let bg = bodyStyles.getPropertyValue('background-color')
|
||||
let txt = bodyStyles.getPropertyValue('color')
|
||||
|
||||
let body = el.target.contentWindow.document.querySelector('body')
|
||||
if (body) {
|
||||
body.style.color = txt
|
||||
body.style.backgroundColor = bg
|
||||
}
|
||||
|
||||
this.resizeIframe(el)
|
||||
},
|
||||
|
||||
// this function is unused but kept here to use for debugging
|
||||
debugDOMPurify(removed) {
|
||||
if (!removed.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const ignoreNodes = ['target', 'base', 'script', 'v:shapes']
|
||||
|
||||
let d = removed.filter((r) => {
|
||||
if (typeof r.attribute != 'undefined' &&
|
||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// inline comments
|
||||
if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (d.length) {
|
||||
console.log(d)
|
||||
}
|
||||
},
|
||||
|
||||
saveTags() {
|
||||
var data = {
|
||||
IDs: [this.message.ID],
|
||||
Tags: this.messageTags
|
||||
}
|
||||
|
||||
this.put(this.resolve('/api/v1/tags'), data, (response) => {
|
||||
window.scrollInPlace = true
|
||||
this.$emit('loadMessages')
|
||||
})
|
||||
},
|
||||
|
||||
// Convert plain text to HTML including anchor links
|
||||
textToHTML(s) {
|
||||
let html = s
|
||||
|
||||
// full links with http(s)
|
||||
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
|
||||
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
|
||||
|
||||
// plain www links without https?:// prefix
|
||||
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
|
||||
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
|
||||
|
||||
// escape to HTML & convert <>" back
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/˱˱˱/g, '<')
|
||||
.replace(/˲˲˲/g, '>')
|
||||
.replace(/ˠˠˠ/g, '"')
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " "
|
||||
}}</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||
{{ message.From.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
|
||||
<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link"
|
||||
:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'"
|
||||
@click="showUnsubscribe = !showUnsubscribe">
|
||||
Unsubscribe
|
||||
<i class="bi bi bi-info-circle"
|
||||
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
||||
class="small">
|
||||
<th class="text-nowrap">Return-Path</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||
{{ message.ReturnPath }}
|
||||
</a>>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th class="small">Date</th>
|
||||
<td>
|
||||
{{ messageDate(message.Date) }}
|
||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
||||
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
||||
data-separator="|,|">
|
||||
<option value="">Type a tag...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in availableTags" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="message.ListUnsubscribe.Header != ''" class="small"
|
||||
:class="showUnsubscribe ? '' : 'd-none'">
|
||||
<th>Unsubscribe</th>
|
||||
<td>
|
||||
<span v-if="message.ListUnsubscribe.Links.length" class="text-secondary small me-2">
|
||||
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
||||
<template v-if="i > 0">, </template>
|
||||
<{{ u }}>
|
||||
</template>
|
||||
</span>
|
||||
<i class="bi bi-info-circle text-success me-2 link"
|
||||
v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost">
|
||||
</i>
|
||||
<i class="bi bi-exclamation-circle text-danger link"
|
||||
v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="message.ListUnsubscribe.Errors">
|
||||
</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||
v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length">
|
||||
<div class="mt-2 mt-md-0">
|
||||
<template v-if="message.Attachments.length">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
||||
Attachment<span v-if="message.Attachments.length > 1">s</span>
|
||||
({{ message.Attachments.length }})
|
||||
</span>
|
||||
<br>
|
||||
</template>
|
||||
<span class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length"
|
||||
title="Inline images in this message">
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
||||
({{ message.Inline.length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
</button>
|
||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
||||
HTML Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
Text
|
||||
</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Checks
|
||||
</button>
|
||||
<ul class="dropdown-menu checks">
|
||||
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showLinkCheck">
|
||||
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false">
|
||||
Link Check
|
||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false" v-if="mailbox.showLinkCheck">
|
||||
Link Check
|
||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="_, key in responsiveSizes">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content mb-5" id="nav-tabContent">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</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>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
|
||||
style="width: 100%; height: 300px"></iframe>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
|
||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
||||
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n"
|
||||
@set-badge-style="(v) => spamScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0" v-if="mailbox.showLinkCheck">
|
||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user