Compare commits

...

72 Commits

Author SHA1 Message Date
Ralph Slooten
91f0515b48 Merge branch 'release/v1.26.2' 2025-06-21 18:20:25 +12:00
Ralph Slooten
88e1aa324b Release v1.26.2 2025-06-21 18:20:22 +12:00
Ralph Slooten
a7e27ea9b7 Chore: Update node dependencies 2025-06-21 18:14:34 +12:00
Ralph Slooten
796749e1a1 Chore: Update Go dependencies 2025-06-21 18:11:16 +12:00
Ralph Slooten
91e4a87995 Merge branch 'feature/linting' into develop 2025-06-21 18:07:27 +12:00
Ralph Slooten
3ad7623e84 Add CONTRIBUTING document 2025-06-21 18:06:38 +12:00
Ralph Slooten
f4954ba115 Move SECURITY document 2025-06-21 18:05:23 +12:00
Ralph Slooten
4195f30d95 Set Go version to stable for rqlite tests 2025-06-21 17:32:51 +12:00
Ralph Slooten
690e82cbfd Add VS Code settings file for vue & JavaScript linting and auto-formatting 2025-06-21 17:19:23 +12:00
Ralph Slooten
2d42c87285 Remove redundant check 2025-06-21 17:03:25 +12:00
Ralph Slooten
c208d71a33 Fix formatting 2025-06-21 00:14:17 +12:00
Ralph Slooten
3cacede2d7 Test: Add Go linting (gofmt) to CI 2025-06-21 00:11:02 +12:00
Ralph Slooten
1886277b6e Test: Add JavaScript linting tests to CI 2025-06-20 23:28:41 +12:00
Ralph Slooten
3fff79e29f Chore: Apply linting to all JavaScript/Vue files with eslint & prettier 2025-06-20 23:26:06 +12:00
Ralph Slooten
7dee371721 Merge branch 'develop' of github.com:axllent/mailpit into develop 2025-06-19 22:30:22 +12:00
Ben Edmunds
95e3ef6fca Feature: Allow version checking to be disabled (#524) 2025-06-19 22:29:20 +12:00
Ralph Slooten
f88a42fda4 Fix docblock casing 2025-06-18 17:27:31 +12:00
Ralph Slooten
3aae06ff6b Fix: Improve version polling, add thread safety and exponential backoff (#523)
Squashed commit of the following:

commit 1ed713dd8de2adb7d761e20bb8018804c2e27ea6
Author: Ralph Slooten <axllent@gmail.com>
Date:   Wed Jun 18 17:03:36 2025 +1200

    Refactor latest version caching, add console logging if update checks fails

commit bf880e583372d81a0597bc263ab22f6989e48fa9
Author: Ben Edmunds <Tigger2014@users.noreply.github.com>
Date:   Wed Jun 18 05:52:35 2025 +0100

    Fix: Improve version polling, add thread safety and exponential backoff (#523)

    * make version polling thread safe and add expo backoff

    * tidy up
2025-06-18 17:04:07 +12:00
Ralph Slooten
4b5ce0afed Feature: Store username with messages, auto-tag, and UI display (#521) 2025-06-18 16:41:04 +12:00
Ralph Slooten
d4ee6fd987 Merge tag 'v1.26.1' into develop
Release v1.26.1
2025-06-14 17:30:10 +12:00
Ralph Slooten
29f4a10d89 Merge branch 'release/v1.26.1' 2025-06-14 17:30:05 +12:00
Ralph Slooten
8f7bf25022 Release v1.26.1 2025-06-14 17:30:03 +12:00
Ralph Slooten
a1110e5ad8 Chore: Update caniemail testing database 2025-06-14 17:27:40 +12:00
Ralph Slooten
31a2ed8824 Test: Add automated tests using the rqlite database 2025-06-14 17:16:54 +12:00
Ralph Slooten
f675ef7b5e Chore: Update node dependencies 2025-06-14 12:37:47 +12:00
Ralph Slooten
4257a89584 Chore: Update Go dependencies 2025-06-14 12:34:17 +12:00
Ralph Slooten
52957cd81f Merge branch 'feature/rqlite-float64' into develop 2025-06-14 12:24:06 +12:00
Ralph Slooten
1520143c45 Test: Add small delay in POP3 test after disconnection to allow for background deletion in rqlite 2025-06-14 12:18:36 +12:00
Ralph Slooten
5107ce0191 Fix: Use float64 for returned SQL value types for rqlite compatibility (#520)
The goqlite library is designed to be flexible and does not make assumptions about the types of JSON values returned from rqlite, using the type `any` for variables. When a numeric value is received in the response, the `any` type does not specify a numeric type, leading Go to default to using `float64`.
2025-06-14 11:52:11 +12:00
Jens-Hilmar Bradt
40afef8ffd Fix: Add optional message_num argument in POP3 LIST command (#518)
https://datatracker.ietf.org/doc/html/rfc1939#page-6

> If an argument was given and the POP3 server issues a positive response with a line containing information for that message.  This line is called a "scan listing" for that message.
2025-06-13 23:13:51 +12:00
Ralph Slooten
fed20de522 Feature: Add relay config to preserve (keep) original Message-IDs when relaying messages (#515) 2025-06-07 11:38:25 +12:00
Ralph Slooten
6999b2ea02 Merge tag 'v1.26.0' into develop
Release v1.26.0
2025-06-06 19:05:51 +12:00
Ralph Slooten
72e92d2d1e Merge branch 'release/v1.26.0' 2025-06-06 19:05:40 +12:00
Ralph Slooten
803adf29ac Release v1.26.0 2025-06-06 19:05:39 +12:00
Ralph Slooten
fb0230a460 Chore: Update node dependencies 2025-06-06 19:01:47 +12:00
Ralph Slooten
873193bcec Chore: Update Go dependencies 2025-06-06 18:59:31 +12:00
Ralph Slooten
e3538cb86a Improve Prometheus GetMode detection 2025-06-06 17:43:20 +12:00
Ralph Slooten
e6ab9e1008 Fix: Fix sendmail symlink detection for macOS (#514) 2025-06-06 17:38:53 +12:00
Ralph Slooten
86f3546bfe Update Prometheus metrics flag description to use 'ip:port' format
Reorder Prometheus flag
2025-06-06 16:11:29 +12:00
Ralph Slooten
a6b5f5f76b Refactor Prometheus metrics configuration and validation 2025-06-06 15:34:06 +12:00
Ben Edmunds
82d7bdc971 Feature: Add Prometheus exporter (#505) 2025-06-06 14:33:49 +12:00
Ralph Slooten
020d5b0fcb Merge branch 'feature/send-auth' into develop 2025-06-02 14:52:43 +12:00
Ralph Slooten
f2b91ac9d5 Chore: Add MP_DATA_FILE deprecation warning 2025-05-30 11:04:20 +12:00
Ralph Slooten
4dff7adc1d Reorder send API CLI flags 2025-05-30 11:03:30 +12:00
Ben Edmunds
9bfdeb5f7b Feature: Send API allow separate auth (#504)
Co-authored-by: Ben Edmunds <ben.edmunds@dotdigital.com>
2025-05-30 08:34:40 +12:00
Ralph Slooten
c5b3edf87d Fix: Ignore basic auth for OPTIONS requests to API when CORS is set
Web browsers do not send authorization headers for  preflight requests.
2025-05-30 00:00:05 +12:00
Ralph Slooten
8c59229f97 Merge tag 'v1.25.1' into develop
Release v1.25.1
2025-05-25 10:12:14 +12:00
Ralph Slooten
56739ceac2 Merge branch 'release/v1.25.1' 2025-05-25 10:12:06 +12:00
Ralph Slooten
5240b1b33e Release v1.25.1 2025-05-25 10:12:05 +12:00
Ralph Slooten
8f80a57c3c Chore: Update node dependencies 2025-05-25 10:10:08 +12:00
Ralph Slooten
04ea905619 Chore: Update Go dependencies 2025-05-25 10:01:06 +12:00
Ralph Slooten
b84b428434 Chore: Add note to swagger docs about API date formats 2025-05-25 09:56:53 +12:00
Ralph Slooten
91409310d7 Chore: Lighten outline-secondary buttons in dark mode 2025-05-23 23:19:54 +12:00
Ralph Slooten
99a3e17243 Fix: Update bootstrap5-tags to fix text pasting in message release modal (#498) 2025-05-23 22:37:06 +12:00
Ralph Slooten
ff272d1c5e Chore: Extend latest version cache expiration from 5 to 15 minutes 2025-05-20 16:55:37 +12:00
Ralph Slooten
74c6a0a434 Chore: Switch from unnecessary float64 to uint64 API values for App Information, message & attachment sizes 2025-05-20 16:51:02 +12:00
Ralph Slooten
e16267ab50 Merge tag 'v1.25.0' into develop
Release v1.25.0
2025-05-18 11:17:31 +12:00
Ralph Slooten
8d86b39385 Merge branch 'release/v1.25.0' 2025-05-18 11:17:22 +12:00
Ralph Slooten
38914348a5 Release v1.25.0 2025-05-18 11:17:20 +12:00
Ralph Slooten
25580b9a68 Chore: Update caniemail database 2025-05-18 10:48:05 +12:00
Ralph Slooten
a1c2690c44 Use text-muted instead of text-secondary 2025-05-18 10:31:39 +12:00
Ralph Slooten
ff8b6326ab Chore: Update node dependencies 2025-05-18 10:31:38 +12:00
Ralph Slooten
5d2966d726 Chore: Update Go dependencies 2025-05-18 10:31:37 +12:00
Ralph Slooten
bf5609a39b Chore: Adjust UI margin for side navigation 2025-05-18 10:31:36 +12:00
Ralph Slooten
4ed5011a8f Chore: Tweak UI to improve contrast between read & unread messages 2025-05-18 10:31:28 +12:00
Ralph Slooten
68d911431f Chore: Switch yaml parser to github.com/goccy/go-yaml
The package gopkg.in/yaml.v3 is now no longer maintained, see https://github.com/go-yaml/yaml
2025-05-17 22:40:11 +12:00
Ralph Slooten
d0716b4995 Feature: Add option to hide the "Delete all" button in web UI (#495) 2025-05-17 12:28:35 +12:00
Ralph Slooten
84a519e84d Fix: Include SMTPUTF8 capability in SMTP EHLO response (#496) 2025-05-17 01:09:17 +12:00
Ralph Slooten
e1a6904eca Chore: Upgrade to jhillyerd/enmime/v2 2025-05-17 00:34:29 +12:00
Ralph Slooten
bc200c663f Docs: Add Message ListUnsubscribe to swagger / API documentation (#494) 2025-05-13 19:27:27 +12:00
Ralph Slooten
009f3a8fd9 Docs: Switch to git-cliff for changelog generation 2025-05-03 23:02:57 +12:00
Ralph Slooten
cfe695c35d Merge tag 'v1.24.2' into develop
Release v1.24.2
2025-05-03 16:17:15 +12:00
97 changed files with 11046 additions and 5074 deletions

View File

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

View File

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

View File

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

View File

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

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

29
.github/workflows/tests-rqlite.yml vendored Normal file
View 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"

View File

@@ -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/htmlcheck ./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: 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
View 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
View 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"
]
}

File diff suppressed because it is too large Load Diff

61
CONTRIBUTING.md Normal file
View 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!

View File

@@ -9,6 +9,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/storage"
@@ -39,6 +40,14 @@ Documentation:
os.Exit(1)
}
// Start Prometheus metrics if enabled
switch prometheus.GetMode() {
case "integrated":
prometheus.StartUpdater()
case "separate":
go prometheus.StartSeparateServer()
}
go server.Listen()
if err := smtpd.Listen(); err != nil {
@@ -84,6 +93,7 @@ func init() {
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
rootCmd.Flags().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
rootCmd.Flags().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")
@@ -106,6 +116,11 @@ func init() {
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")
@@ -144,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")
@@ -186,6 +205,8 @@ func initConfigFromEnv() {
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"))
}
@@ -244,6 +265,18 @@ func initConfigFromEnv() {
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 {
@@ -302,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")
@@ -341,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 {
@@ -358,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")

View File

@@ -72,6 +72,12 @@ var (
// 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
@@ -123,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
@@ -173,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
@@ -182,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"
@@ -197,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
)
@@ -224,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"`
@@ -286,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)
@@ -320,6 +341,49 @@ 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")
}

View File

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

View File

@@ -12,7 +12,7 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"gopkg.in/yaml.v3"
"github.com/goccy/go-yaml"
)
// Parse the --max-age value (if set)

View File

@@ -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
View 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",
},
},
];

43
go.mod
View File

@@ -4,60 +4,69 @@ go 1.23.0
toolchain go1.23.2
// https://github.com/jaytaylor/html2text/issues/67
replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
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/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.9.1
github.com/spf13/pflag v1.0.6
github.com/tg123/go-htpasswd v1.2.4
github.com/vanng822/go-premailer v1.24.0
golang.org/x/net v0.39.0
golang.org/x/text v0.24.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.37.0
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.37.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.65.0 // 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.10.0 // indirect
modernc.org/memory v1.11.0 // indirect
)

118
go.sum
View File

@@ -8,22 +8,29 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhP
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/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-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/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=
@@ -38,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/jhillyerd/enmime/v2 v2.1.0 h1:c8Qwi5Xq5EdtMN6byQWoZ/8I2RMTo6OJ7Xay+s1oPO0=
github.com/jhillyerd/enmime/v2 v2.1.0/go.mod h1:EJ74dcRbBcqHSP2TBu08XRoy6y3Yx0cevwb1YkGMEmQ=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
@@ -65,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=
@@ -73,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=
@@ -81,10 +93,8 @@ 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=
@@ -110,8 +120,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.24.0 h1:b4MpHLVdlA7QOwk5OJIEvWnIpCCdEhEDQpJ/AkEYcpo=
github.com/vanng822/go-premailer v1.24.0/go.mod h1:gjLku4P5inmyu+MM7544lOjhaW8F3TdIqboFVcZGwZE=
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=
@@ -119,19 +129,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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=
@@ -141,8 +151,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
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=
@@ -150,8 +160,8 @@ 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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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=
@@ -165,8 +175,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
@@ -185,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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
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.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/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=

View File

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

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2025-04-24 15:44:31 +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":[
{
@@ -1822,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."}
},
{
@@ -1886,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."}
},
@@ -2622,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
},
@@ -3550,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."}
},
@@ -3769,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",
@@ -3886,7 +3886,7 @@
"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
},

View File

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

View File

@@ -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 {

View File

@@ -0,0 +1,190 @@
// Package prometheus provides Prometheus metrics for Mailpit
package prometheus
import (
"net/http"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/stats"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
// Registry is the Prometheus registry for Mailpit metrics
Registry = prometheus.NewRegistry()
// Metrics
totalMessages prometheus.Gauge
unreadMessages prometheus.Gauge
databaseSize prometheus.Gauge
messagesDeleted prometheus.Counter
smtpAccepted prometheus.Counter
smtpRejected prometheus.Counter
smtpIgnored prometheus.Counter
smtpAcceptedSize prometheus.Counter
uptime prometheus.Gauge
memoryUsage prometheus.Gauge
tagCounters *prometheus.GaugeVec
)
// InitMetrics initializes all Prometheus metrics
func InitMetrics() {
// Create metrics
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_messages",
Help: "Total number of messages in the database",
})
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_messages_unread",
Help: "Number of unread messages in the database",
})
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_database_size_bytes",
Help: "Size of the database in bytes",
})
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_messages_deleted_total",
Help: "Total number of messages deleted",
})
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_accepted_total",
Help: "Total number of SMTP messages accepted",
})
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_rejected_total",
Help: "Total number of SMTP messages rejected",
})
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_ignored_total",
Help: "Total number of SMTP messages ignored (duplicates)",
})
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
Name: "mailpit_smtp_accepted_size_bytes_total",
Help: "Total size of accepted SMTP messages in bytes",
})
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_uptime_seconds",
Help: "Uptime of Mailpit in seconds",
})
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "mailpit_memory_usage_bytes",
Help: "Memory usage in bytes",
})
tagCounters = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "mailpit_tag_messages",
Help: "Number of messages per tag",
},
[]string{"tag"},
)
// Register metrics
Registry.MustRegister(totalMessages)
Registry.MustRegister(unreadMessages)
Registry.MustRegister(databaseSize)
Registry.MustRegister(messagesDeleted)
Registry.MustRegister(smtpAccepted)
Registry.MustRegister(smtpRejected)
Registry.MustRegister(smtpIgnored)
Registry.MustRegister(smtpAcceptedSize)
Registry.MustRegister(uptime)
Registry.MustRegister(memoryUsage)
Registry.MustRegister(tagCounters)
}
// UpdateMetrics updates all metrics with current values
func UpdateMetrics() {
info := stats.Load()
totalMessages.Set(float64(info.Messages))
unreadMessages.Set(float64(info.Unread))
databaseSize.Set(float64(info.DatabaseSize))
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
uptime.Set(float64(info.RuntimeStats.Uptime))
memoryUsage.Set(float64(info.RuntimeStats.Memory))
// Reset tag counters
tagCounters.Reset()
// Update tag counters
for tag, count := range info.Tags {
tagCounters.WithLabelValues(tag).Set(float64(count))
}
}
// Returns the Prometheus handler & disables double compression in middleware
func GetHandler() http.Handler {
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
DisableCompression: true,
})
}
// StartUpdater starts the periodic metrics update routine
func StartUpdater() {
InitMetrics()
UpdateMetrics()
// Start periodic updates
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
UpdateMetrics()
}
}()
}
// StartSeparateServer starts a separate HTTP server for Prometheus metrics
func StartSeparateServer() {
StartUpdater()
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
// Create a dedicated mux for the metrics server
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
// Create a dedicated server instance
server := &http.Server{
Addr: config.PrometheusListen,
Handler: mux,
}
// Start HTTP server
if err := server.ListenAndServe(); err != nil {
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
}
}
// GetMode returns the Prometheus run mode
func GetMode() string {
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
switch {
case mode == "false", mode == "":
return "disabled"
case mode == "true":
return "integrated"
default:
return "separate"
}
}

View File

@@ -28,12 +28,12 @@ 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) {
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
@@ -110,7 +110,7 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
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

View File

@@ -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.
@@ -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
}

View File

@@ -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()
}

View File

@@ -32,7 +32,7 @@ func dbCron() {
if total == 0 {
deletedPercent = 100
} else {
deletedPercent = deletedSize * 100 / total
deletedPercent = float64(deletedSize * 100 / total)
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
@@ -56,13 +56,13 @@ func pruneMessages() {
start := time.Now()
ids := []string{}
var prunedSize int64
var size float64
var prunedSize uint64
var size 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 {

View File

@@ -210,52 +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
return uint64(total.Float64)
}
// MessageIDExists checks whether a Message-ID exists in the DB

View File

@@ -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
@@ -145,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)...))
@@ -201,32 +210,41 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created 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
@@ -271,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"), "<>")
@@ -294,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())
@@ -302,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())
@@ -321,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{}
@@ -462,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
}
@@ -617,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 {
@@ -663,7 +690,7 @@ func DeleteMessages(ids []string) error {
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))
addDeletedSize(totalSize)
logMessagesDeleted(len(toDelete))
@@ -745,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
}

View File

@@ -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))
@@ -94,7 +94,7 @@ func TestRetrieveMimeEmail(t *testing.T) {
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
}
id, err := Store(&testMimeEmail)
id, err := Store(&testMimeEmail, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -122,14 +122,14 @@ func TestRetrieveMimeEmail(t *testing.T) {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
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, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
assertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
Close()
}
@@ -151,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()
}
@@ -185,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()
}
@@ -197,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()
}

View File

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

View File

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

View File

@@ -39,12 +39,12 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var created 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
@@ -111,7 +111,7 @@ func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
q = q.Where(`Created < ?`, beforeTS)
}
var unread int64
var unread float64 // use float64 for rqlite compatibility
q = q.Where("Read = 0").Select(`COUNT(*)`)
@@ -128,9 +128,9 @@ func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", unread, search, elapsed)
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed)
return unread, err
return int64(unread), err
}
// DeleteSearch will delete all messages for search terms.
@@ -141,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
@@ -161,7 +161,7 @@ func DeleteSearch(search, timezone string) error {
}
ids = append(ids, id)
deleteSize = deleteSize + size
deleteSize = deleteSize + uint64(size)
}); err != nil {
return err
}
@@ -247,7 +247,7 @@ func DeleteSearch(search, timezone string) error {
}
}
addDeletedSize(int64(deleteSize))
addDeletedSize(deleteSize)
logMessagesDeleted(total)
@@ -264,12 +264,12 @@ func SetSearchReadStatus(search, timezone string, read bool) error {
ids := []string{}
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
@@ -519,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})?$`)
@@ -537,15 +537,15 @@ func sizeToBytes(v string) int64 {
}
if unit == "" {
return int64(i)
return uint64(i)
}
if unit == "k" || unit == "kb" {
return int64(i * 1024)
return uint64(i * 1024)
}
if unit == "m" || unit == "mb" {
return int64(i * 1024 * 1024)
return uint64(i * 1024 * 1024)
}
return 0

View File

@@ -7,7 +7,7 @@ import (
"testing"
"github.com/axllent/mailpit/config"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
func TestSearch(t *testing.T) {
@@ -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

View File

@@ -35,8 +35,8 @@ func SettingPut(k, v string) error {
}
// The total deleted message size as an int64 value
func getDeletedSize() float64 {
var result sql.NullFloat64
func getDeletedSize() uint64 {
var result sql.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())
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}
}
})
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
// package Updater checks and downloads new versions
// Package updater checks and downloads new versions
package updater
import (

26
main.go
View File

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

5447
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -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
}
}

View File

@@ -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 {

View File

@@ -40,21 +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 float64 `json:"messages_unread"`
MessagesUnreadCount uint64 `json:"messages_unread"`
// Pagination offset
Start int `json:"start"`
@@ -98,7 +98,7 @@ 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
@@ -349,9 +349,9 @@ 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
@@ -361,7 +361,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
return
}
res.MessagesUnreadCount = float64(unread)
res.MessagesUnreadCount = uint64(unread)
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {

View File

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

View File

@@ -176,13 +176,14 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-ID with unique ID
msg, err = tools.SetMessageHeader(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 {

View File

@@ -14,7 +14,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/enmime/v2"
)
// swagger:parameters SendMessageParams
@@ -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)
}

View File

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

View File

@@ -19,6 +19,7 @@ import (
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
@@ -158,7 +159,7 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
@@ -182,6 +183,13 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
// Prometheus metrics (if enabled and using existing server)
if prometheus.GetMode() == "integrated" {
r.HandleFunc(config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
prometheus.GetHandler().ServeHTTP(w, r)
})).Methods("GET")
}
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
@@ -198,6 +206,48 @@ func basicAuthResponse(w http.ResponseWriter) {
_, _ = w.Write([]byte("Unauthorised.\n"))
}
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
// It can use dedicated send API authentication or accept any credentials based on configuration
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// If send API auth accept any is enabled, bypass all authentication
if config.SendAPIAuthAcceptAny {
// Temporarily disable UI auth for this request
originalCredentials := auth.UICredentials
auth.UICredentials = nil
defer func() { auth.UICredentials = originalCredentials }()
// Call the standard middleware
middleWareFunc(fn)(w, r)
return
}
// If Send API credentials are configured, only accept those credentials
if auth.SendAPICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.SendAPICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
// Valid Send API credentials - bypass UI auth and call function directly
originalCredentials := auth.UICredentials
auth.UICredentials = nil
defer func() { auth.UICredentials = originalCredentials }()
middleWareFunc(fn)(w, r)
return
}
// No Send API credentials configured - fall back to UI auth
middleWareFunc(fn)(w, r)
}
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
@@ -239,7 +289,9 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
// Check basic authentication headers if configured.
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight.
if !(AccessControlAllowOrigin != "" && r.Method == http.MethodOptions) && auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {

View File

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

View File

@@ -1,42 +1,41 @@
<script>
import CommonMixins from './mixins/CommonMixins'
import Favicon from './components/Favicon.vue'
import AppBadge from './components/AppBadge.vue'
import Notifications from './components/Notifications.vue'
import EditTags from './components/EditTags.vue'
import { mailbox } from "./stores/mailbox"
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>

View File

@@ -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");

View File

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

View File

@@ -89,6 +89,14 @@
.token.property {
color: #ee6969;
}
.btn-outline-secondary {
color: #9c9c9c;
&:hover {
color: $body-color-dark;
}
}
}
.text-spaces-nowrap {
@@ -228,27 +236,24 @@
}
}
#message-page {
#message-page,
#MessageList {
.list-group-item.message:first-child {
border-top: 0;
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
.message:not(.active) {
b {
color: $list-group-color;
}
&.read {
color: $text-muted;
> div {
opacity: 0.5;
}
b {
color: $list-group-color;
}

View File

@@ -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>

View File

@@ -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>

View File

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

View 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>

View 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>

View 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> &amp;
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 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.messages_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.messages_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>

View 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>

View File

@@ -1,79 +1,79 @@
<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;
}
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)
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('/')
})
this.$router.push("/");
});
},
markAllRead() {
const s = this.getSearch()
const s = this.getSearch();
if (!s) {
return
return;
}
let uri = this.resolve(`/api/v1/messages`)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
uri += '?tz=' + encodeURIComponent(mailbox.timeZone)
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()
})
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 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>
@@ -83,80 +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"
:disabled="!mailbox.messages_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.messages_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"
@click="deleteAllMessages" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<!-- 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="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 search results as read?</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 mark {{ formatNumber(mailbox.messages_unread) }}
message<span v-if="mailbox.messages_unread > 1">s</span>
This will mark {{ formatNumber(mailbox.messages_unread) }} message<span
v-if="mailbox.messages_unread > 1"
>s</span
>
matching <code>{{ getSearch() }}</code>
as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markAllRead">Confirm</button>
<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 matching search?</h5>
<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
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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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> &amp; <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>

View File

@@ -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> &nbsp;
</span>
&nbsp;
<span class="text-nowrap">
<i class="bi bi-circle-fill text-warning"></i>
{{ round2dm(summary.Total.Partial) }}% partially supported
</span> &nbsp;
</span>
&nbsp;
<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">

View File

@@ -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>

View File

@@ -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 &amp; 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 &amp; 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 &amp; 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">

View File

@@ -1,644 +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 Tags from 'bootstrap5-tags'
import { Tooltip } from 'bootstrap'
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import xml from 'highlight.js/lib/languages/xml'
hljs.registerLanguage('html', xml)
export default {
props: {
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
hljs.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.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">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body">
{{ t.Address }}
</a>&gt;
</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>
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary">
{{ t.Address }}
</a>&gt;
</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">
&lt;<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
{{ message.ReturnPath }}
</a>&gt;
</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>
&lt;{{ u }}&gt;
</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 class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
<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>

View File

@@ -1,84 +1,102 @@
<script>
import commonMixins from '../../mixins/CommonMixins'
import ICAL from "ical.js"
import dayjs from 'dayjs'
import commonMixins from "../../mixins/CommonMixins";
import ICAL from "ical.js";
import dayjs from "dayjs";
export default {
props: {
message: Object,
attachments: Object
},
mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
attachments: {
type: Object,
required: true,
},
},
data() {
return {
ical: false
}
ical: false,
};
},
methods: {
openAttachment(part, e) {
let filename = part.FileName
let contentType = part.ContentType
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
e.preventDefault()
const filename = part.FileName;
const contentType = part.ContentType;
const href = this.resolve("/api/v1/message/" + this.message.ID + "/part/" + part.PartID);
if (filename.match(/\.ics$/i) || contentType === "text/calendar") {
e.preventDefault();
this.get(href, null, (response) => {
let comp = new ICAL.Component(ICAL.parse(response.data))
let vevent = comp.getFirstSubcomponent('vevent')
const comp = new ICAL.Component(ICAL.parse(response.data));
const vevent = comp.getFirstSubcomponent("vevent");
if (!vevent) {
alert('Error parsing ICS file')
return
alert("Error parsing ICS file");
return;
}
let event = new ICAL.Event(vevent)
const event = new ICAL.Event(vevent);
let summary = {}
summary.link = href
summary.status = vevent.getFirstPropertyValue('status')
summary.url = vevent.getFirstPropertyValue('url')
summary.summary = event.summary
summary.description = event.description
summary.location = event.location
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a')
summary.isRecurring = event.isRecurring()
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
summary.attendees = []
const summary = {};
summary.link = href;
summary.status = vevent.getFirstPropertyValue("status");
summary.url = vevent.getFirstPropertyValue("url");
summary.summary = event.summary;
summary.description = event.description;
summary.location = event.location;
summary.start = dayjs(event.startDate).format("ddd, D MMM YYYY, h:mm a");
summary.end = dayjs(event.endDate).format("ddd, D MMM YYYY, h:mm a");
summary.isRecurring = event.isRecurring();
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, "") : false;
summary.attendees = [];
event.attendees.forEach((a) => {
if (a.jCal[1].cn) {
summary.attendees.push(a.jCal[1].cn)
summary.attendees.push(a.jCal[1].cn);
}
})
});
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
})
summary.timezone = vtimezone.getFirstPropertyValue("tzid");
});
this.ical = summary
this.ical = summary;
// display modal
this.modal('ICSView').show()
})
this.modal("ICSView").show();
});
}
}
},
},
}
};
</script>
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
@click="openAttachment(part, $event)">
<img v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
alt="">
<img v-else
<a
v-for="part in attachments"
:key="part.PartID"
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3"
target="_blank"
style="width: 180px"
@click="openAttachment(part, $event)"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top" alt="">
<div class="icon" v-if="!isImage(part)">
class="card-img-top"
alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
@@ -87,16 +105,16 @@ export default {
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div>
</a>
</div>
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true">
<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header">
@@ -106,7 +124,7 @@ export default {
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" v-if="ical">
<div v-if="ical" class="modal-body">
<table class="table">
<tbody>
<tr v-if="ical.summary">
@@ -126,7 +144,7 @@ export default {
</tr>
<tr v-if="ical.status">
<th>Status</th>
<td> {{ ical.status }}</td>
<td>{{ ical.status }}</td>
</tr>
<tr v-if="ical.location">
<th>Location</th>
@@ -134,7 +152,9 @@ export default {
</tr>
<tr v-if="ical.url">
<th>URL</th>
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td>
<td>
<a :href="ical.url" target="_blank">{{ ical.url }}</a>
</td>
</tr>
<tr v-if="ical.organizer">
<th>Organizer</th>
@@ -143,7 +163,7 @@ export default {
<tr v-if="ical.attendees.length">
<th>Attendees</th>
<td>
<span v-for="(a, i) in ical.attendees">
<span v-for="(a, i) in ical.attendees" :key="'attendee_' + i">
<template v-if="i > 0">,</template>
{{ a }}
</span>
@@ -154,12 +174,9 @@ export default {
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a class="btn btn-primary" target="_blank" :href="ical.link">
Download attachment
</a>
<a class="btn btn-primary" target="_blank" :href="ical.link"> Download attachment </a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script>
import commonMixins from "../../mixins/CommonMixins";
export default {
mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
},
data() {
return {
headers: false,
};
},
mounted() {
const 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" :key="'headers_' + k" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2">
<b>{{ k }}</b>
</div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="(x, i) in values" :key="'line_' + i" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,861 @@
<script>
import Attachments from "./MessageAttachments.vue";
import Headers from "./MessageHeaders.vue";
import HTMLCheck from "./HTMLCheck.vue";
import LinkCheck from "./LinkCheck.vue";
import SpamAssassin from "./SpamAssassin.vue";
import Tags from "bootstrap5-tags";
import { Tooltip } from "bootstrap";
import commonMixins from "../../mixins/CommonMixins";
import { mailbox } from "../../stores/mailbox";
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core";
import xml from "highlight.js/lib/languages/xml";
hljs.registerLanguage("html", xml);
export default {
components: {
Attachments,
Headers,
HTMLCheck,
LinkCheck,
SpamAssassin,
},
mixins: [commonMixins],
props: {
message: {
type: Object,
required: true,
},
},
emits: ["loadMessages"],
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%",
},
};
},
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;
},
},
watch: {
messageTags() {
if (this.canSaveTags) {
// save changes to tags
this.saveTags();
}
},
scaleHTMLPreview(v) {
if (v === "display") {
window.setTimeout(() => {
this.resizeIFrames();
}, 500);
}
},
},
mounted() {
this.canSaveTags = false;
this.messageTags = this.message.Tags;
this.renderUI();
window.addEventListener("resize", this.resizeIFrames);
const headersTab = document.getElementById("nav-headers-tab");
headersTab.addEventListener("shown.bs.tab", (event) => {
this.loadHeaders = true;
});
const 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(() => {
const p = document.getElementById("preview-html");
if (p && typeof p.contentWindow.document.body === "object") {
try {
// make links open in new window
const anchorEls = p.contentWindow.document.body.querySelectorAll("a");
for (let i = 0; i < anchorEls.length; i++) {
const anchorEl = anchorEls[i];
const href = anchorEl.getAttribute("href");
if (href && href.match(/^https?:\/\//i)) {
anchorEl.setAttribute("target", "_blank");
}
}
} catch (error) {}
this.resizeIFrames();
}
}, 500);
// HTML highlighting
hljs.highlightAll();
},
resizeIframe(el) {
const 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;
}
const h = document.getElementById("preview-html");
if (h) {
if (typeof h.contentWindow.document.body.scrollHeight === "number") {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + "px";
}
}
},
// set the iframe body & text colors based on current theme
initRawIframe(el) {
const bodyStyles = window.getComputedStyle(document.body, null);
const bg = bodyStyles.getPropertyValue("background-color");
const txt = bodyStyles.getPropertyValue("color");
const 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"];
const 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() {
const 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)
const re = /(\b(https?|ftp):\/\/[-\w@:%_+'!.~#?,&//=;]+)/gim;
html = html.replace(re, "˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲");
// plain www links without https?:// prefix
const 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.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">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }} </a
>&gt;
</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">
<template v-if="message.To && message.To.length">
<span v-for="(t, i) in message.To" :key="'to_' + i">
<template v-if="i > 0">, </template>
<span>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a
>&gt;
</span>
</span>
</template>
<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" :key="'cc_' + i">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>&gt;
</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" :key="'bcc_' + i">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body"> {{ t.Address }} </a>&gt;
</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" :key="'bcc_' + i">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary"> {{ t.Address }} </a
>&gt;
</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">
&lt;<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
{{ message.ReturnPath }} </a
>&gt;
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td>
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
<small v-else class="text-body-secondary">[ 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 v-if="message.Username" class="small">
<th class="small">
Username
<i
class="bi bi-exclamation-circle ms-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-custom-class="custom-tooltip"
data-bs-title="The SMTP or send API username the client authenticated with"
>
</i>
</th>
<td class="small">
{{ message.Username }}
</td>
</tr>
<tr class="small">
<th>Tags</th>
<td>
<select
v-model="messageTags"
class="form-select small tag-selector"
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" :key="t" :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-muted small me-2">
<template v-for="(u, i) in message.ListUnsubscribe.Links">
<template v-if="i > 0">, </template>
&lt;{{ u }}&gt;
</template>
</span>
<i
v-if="message.ListUnsubscribe.HeaderPost != ''"
class="bi bi-info-circle text-success me-2 link"
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
v-if="message.ListUnsubscribe.Errors != ''"
class="bi bi-exclamation-circle text-danger link"
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
v-if="(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)"
class="col-md-auto d-none d-md-block text-end mt-md-3"
>
<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
v-if="message.Inline.length"
class="badge rounded-pill text-bg-secondary p-2"
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 id="nav-tab" class="nav nav-tabs my-3 d-print-none" role="tablist">
<template v-if="message.HTML">
<div class="btn-group">
<button
id="nav-html-tab"
ref="navhtml"
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#nav-html"
type="button"
role="tab"
aria-controls="nav-html"
aria-selected="true"
@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
id="nav-html-source-tab"
class="nav-link d-none d-sm-inline"
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
id="nav-plain-text-tab"
class="nav-link"
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
id="nav-headers-tab"
class="nav-link"
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
id="nav-raw-tab"
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#nav-raw"
type="button"
role="tab"
aria-controls="nav-raw"
aria-selected="false"
>
Raw
</button>
<div v-show="hasAnyChecksEnabled" class="dropdown d-xl-none">
<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
id="nav-html-check-tab"
class="dropdown-item"
data-bs-toggle="tab"
data-bs-target="#nav-html-check"
type="button"
role="tab"
aria-controls="nav-html"
aria-selected="false"
>
HTML Check
<span
v-if="htmlScore !== false"
class="badge rounded-pill p-1 float-end"
:class="htmlScoreColor"
>
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
</li>
<li v-if="mailbox.showLinkCheck">
<button
id="nav-link-check-tab"
class="dropdown-item"
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 v-if="linkCheckErrors === 0" class="badge rounded-pill bg-success float-end">
<small>0</small>
</span>
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger float-end">
<small>{{ formatNumber(linkCheckErrors) }}</small>
</span>
</button>
</li>
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
<button
id="nav-spam-check-tab"
class="dropdown-item"
data-bs-toggle="tab"
data-bs-target="#nav-spam-check"
type="button"
role="tab"
aria-controls="nav-html"
aria-selected="false"
>
Spam Analysis
<span
v-if="spamScore !== false"
class="badge rounded-pill float-end"
:class="spamScoreColor"
>
<small>{{ spamScore }}</small>
</span>
</button>
</li>
</ul>
</div>
<button
v-if="mailbox.showHTMLCheck && message.HTML != ''"
id="nav-html-check-tab"
class="d-none d-xl-inline-block nav-link position-relative"
data-bs-toggle="tab"
data-bs-target="#nav-html-check"
type="button"
role="tab"
aria-controls="nav-html"
aria-selected="false"
>
HTML Check
<span v-if="htmlScore !== false" class="badge rounded-pill p-1" :class="htmlScoreColor">
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
<button
v-if="mailbox.showLinkCheck"
id="nav-link-check-tab"
class="d-none d-xl-inline-block nav-link"
data-bs-toggle="tab"
data-bs-target="#nav-link-check"
type="button"
role="tab"
aria-controls="nav-link-check"
aria-selected="false"
>
Link Check
<i v-if="linkCheckErrors === 0" class="bi bi-check-all text-success"></i>
<span v-else-if="linkCheckErrors > 0" class="badge rounded-pill bg-danger">
<small>{{ formatNumber(linkCheckErrors) }}</small>
</span>
</button>
<button
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
id="nav-spam-check-tab"
class="d-none d-xl-inline-block nav-link position-relative"
data-bs-toggle="tab"
data-bs-target="#nav-spam-check"
type="button"
role="tab"
aria-controls="nav-html"
aria-selected="false"
>
Spam Analysis
<span v-if="spamScore !== false" class="badge rounded-pill" :class="spamScoreColor">
<small>{{ spamScore }}</small>
</span>
</button>
<div v-if="showMobileButtons" class="d-none d-lg-block ms-auto me-3">
<template v-for="(_, key) in responsiveSizes" :key="'responsive_' + key">
<button
class="btn"
:disabled="scaleHTMLPreview == key"
:title="'Switch to ' + key + ' view'"
@click="scaleHTMLPreview = key"
>
<i class="bi" :class="'bi-' + key"></i>
</button>
</template>
</div>
</nav>
<div id="nav-tabContent" class="tab-content mb-5">
<div
v-if="message.HTML != ''"
id="nav-html"
class="tab-pane fade show"
role="tabpanel"
aria-labelledby="nav-html-tab"
tabindex="0"
>
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe
id="preview-html"
target-blank=""
class="tab-pane d-block"
:srcdoc="sanitizedHTML"
frameborder="0"
style="width: 100%; height: 100%; background: #fff"
@load="resizeIframe"
>
</iframe>
</div>
<Attachments
v-if="allAttachments(message).length"
:message="message"
:attachments="allAttachments(message)"
>
</Attachments>
</div>
<div
v-if="message.HTML"
id="nav-html-source"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="nav-html-source-tab"
tabindex="0"
>
<pre class="language-html"><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div
id="nav-plain-text"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="nav-plain-text-tab"
tabindex="0"
:class="message.HTML == '' ? 'show' : ''"
>
<!-- eslint-disable vue/no-v-html -->
<div class="text-view" v-html="textToHTML(message.Text)"></div>
<!-- -eslint-disable vue/no-v-html -->
<Attachments
v-if="allAttachments(message).length"
:message="message"
:attachments="allAttachments(message)"
>
</Attachments>
</div>
<div id="nav-headers" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if="loadHeaders" :message="message"></Headers>
</div>
<div id="nav-raw" class="tab-pane fade" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
<iframe
v-if="srcURI"
:src="srcURI"
frameborder="0"
style="width: 100%; height: 300px"
@load="initRawIframe"
></iframe>
</div>
<div
id="nav-html-check"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="nav-html-check-tab"
tabindex="0"
>
<HTMLCheck
v-if="mailbox.showHTMLCheck && message.HTML != ''"
:message="message"
@set-html-score="(n) => (htmlScore = n)"
@set-badge-style="(v) => (htmlScoreColor = v)"
/>
</div>
<div
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin"
id="nav-spam-check"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="nav-spam-check-tab"
tabindex="0"
>
<SpamAssassin
:message="message"
@set-spam-score="(n) => (spamScore = n)"
@set-badge-style="(v) => (spamScoreColor = v)"
/>
</div>
<div
v-if="mailbox.showLinkCheck"
id="nav-link-check"
class="tab-pane fade"
role="tabpanel"
aria-labelledby="nav-html-check-tab"
tabindex="0"
>
<LinkCheck :message="message" @set-link-errors="(n) => (linkCheckErrors = n)" />
</div>
</div>
</div>
</template>

View File

@@ -1,19 +1,24 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import Tags from "bootstrap5-tags"
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
import AjaxLoader from "../AjaxLoader.vue";
import Tags from "bootstrap5-tags";
import commonMixins from "../../mixins/CommonMixins";
import { mailbox } from "../../stores/mailbox";
export default {
props: {
message: Object,
},
components: {
AjaxLoader,
},
emits: ['delete'],
mixins: [commonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
emits: ["delete"],
data() {
return {
@@ -21,64 +26,62 @@ export default {
deleteAfterRelease: false,
mailbox,
allAddresses: [],
}
};
},
mixins: [commonMixins],
mounted() {
let a = []
for (let i in this.message.To) {
a.push(this.message.To[i].Address)
const a = [];
for (const i in this.message.To) {
a.push(this.message.To[i].Address);
}
for (let i in this.message.Cc) {
a.push(this.message.Cc[i].Address)
for (const i in this.message.Cc) {
a.push(this.message.Cc[i].Address);
}
for (let i in this.message.Bcc) {
a.push(this.message.Bcc[i].Address)
for (const i in this.message.Bcc) {
a.push(this.message.Bcc[i].Address);
}
// include only unique email addresses, regardless of casing
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()]))
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));
this.addresses = this.allAddresses
this.addresses = this.allAddresses;
},
methods: {
// triggered manually after modal is shown
initTags() {
Tags.init("select[multiple]")
Tags.init("select[multiple]");
},
releaseMessage() {
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(() => {
if (!this.addresses.length) {
return false
return false;
}
let data = {
To: this.addresses
}
const data = {
To: this.addresses,
};
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
this.modal("ReleaseModal").hide()
this.post(this.resolve("/api/v1/message/" + this.message.ID + "/release"), data, (response) => {
this.modal("ReleaseModal").hide();
if (this.deleteAfterRelease) {
this.$emit('delete')
this.$emit("delete");
}
})
}, 100)
}
}
}
});
}, 100);
},
},
};
</script>
<template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" v-if="message">
<div id="ReleaseModal" class="modal fade" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div v-if="message" class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<h1 id="AppInfoModalLabel" class="modal-title fs-5">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -86,32 +89,55 @@ export default {
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
aria-label="From address" readonly class="form-control-plaintext"
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From ? message.From.Address : ''">
<input
v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''"
type="text"
aria-label="From address"
readonly
class="form-control-plaintext"
:value="mailbox.uiConfig.MessageRelay.OverrideFrom"
/>
<input
v-else
type="text"
aria-label="From address"
readonly
class="form-control-plaintext"
:value="message.From ? message.From.Address : ''"
/>
</div>
</div>
<div class="row">
<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label>
<label class="col-sm-2 col-form-label text-body-secondary">Subject</label>
<div class="col-sm-10">
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
:value="message.Subject">
<input
type="text"
aria-label="Subject"
readonly
class="form-control-plaintext"
:value="message.Subject"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
<div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
data-clear-end="true" data-allow-clear="true"
data-placeholder="Enter email addresses..." data-add-on-blur="true"
<select
v-model="addresses"
class="form-select tag-selector"
multiple
data-allow-new="true"
data-clear-end="true"
data-allow-clear="true"
data-placeholder="Enter email addresses..."
data-add-on-blur="true"
data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|">
data-separator="|,|"
>
<option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allAddresses" :value="t">{{ t }}</option>
<option v-for="t in allAddresses" :key="'address+' + t" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
@@ -119,8 +145,12 @@ export default {
<div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="deleteAfterRelease"
id="DeleteAfterRelease">
<input
id="DeleteAfterRelease"
v-model="deleteAfterRelease"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="DeleteAfterRelease">
Delete the message after release
</label>
@@ -140,12 +170,13 @@ export default {
will be rejected:
<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>
</li>
<li class="form-text">
For testing purposes, a new unique <code>Message-Id</code> will be generated on send.
<li v-if="!mailbox.uiConfig.MessageRelay.PreserveMessageIDs" class="form-text">
For testing purposes, a new unique <code>Message-ID</code> will be generated on send.
</li>
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
The <code>From</code> email address has been overridden by the relay configuration to
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>.
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code
>.
</li>
<li class="form-text">
SMTP delivery failures will bounce back to
@@ -155,14 +186,16 @@ export default {
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
</code>
<code v-else>{{ message.ReturnPath }}</code>.
<code v-else>{{ message.ReturnPath }}</code
>.
</li>
</ul>
</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-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length" @click="releaseMessage">
Release
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,165 @@
<script>
import AjaxLoader from "../AjaxLoader.vue";
import CommonMixins from "../../mixins/CommonMixins";
import { domToPng } from "modern-screenshot";
export default {
components: {
AjaxLoader,
},
mixins: [CommonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
data() {
return {
html: false,
loading: 0,
};
},
methods: {
initScreenshot() {
this.loading = 1;
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/im, "");
const proxy = this.resolve("/proxy");
// Outlook hacks - else screenshot returns blank image
h = h.replace(/<html [^>]+>/gim, "<html>"); // remove html attributes
h = h.replace(/<o:p><\/o:p>/gm, ""); // remove empty `<o:p></o:p>` tags
h = h.replace(/<o:/gm, "<"); // replace `<o:p>` tags with `<p>`
h = h.replace(/<\/o:/gm, "</"); // replace `</o:p>` tags with `</p>`
// update any inline `url(...)` absolute links
const urlRegex = /(url\(('|")?(https?:\/\/[^)'"]+)('|")?\))/gim;
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === "string") {
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`;
}
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`;
});
// create temporary document to manipulate
const doc = document.implementation.createHTMLDocument();
doc.open();
doc.write(h);
doc.close();
// remove any <script> tags
const scripts = doc.getElementsByTagName("script");
for (const i of scripts) {
i.parentNode.removeChild(i);
}
// replace stylesheet links with proxy links
const stylesheets = doc.getElementsByTagName("link");
for (const i of stylesheets) {
const src = i.getAttribute("href");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
i.setAttribute("href", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// replace images with proxy links
const images = doc.getElementsByTagName("img");
for (const i of images) {
const src = i.getAttribute("src");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
i.setAttribute("src", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// replace background="" attributes with proxy links
const backgrounds = doc.querySelectorAll("[background]");
for (const i of backgrounds) {
const src = i.getAttribute("background");
if (
src &&
src.match(/^https?:\/\//i) &&
src.indexOf(window.location.origin + window.location.pathname) !== 0
) {
// replace with proxy link
i.setAttribute("background", `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)));
}
}
// set html with manipulated document content
this.html = new XMLSerializer().serializeToString(doc);
},
// HTML decode function
decodeEntities(s) {
const e = document.createElement("div");
e.innerHTML = s;
const str = e.textContent;
e.textContent = "";
return str;
},
doScreenshot() {
let width = document.getElementById("message-view").getBoundingClientRect().width;
const prev = document.getElementById("preview-html");
if (prev && prev.getBoundingClientRect().width) {
width = prev.getBoundingClientRect().width;
}
if (width < 300) {
width = 300;
}
const i = document.getElementById("screenshot-html");
// set the iframe width
i.style.width = width + "px";
const body = i.contentWindow.document.querySelector("body");
// take screenshot of iframe
domToPng(body, {
backgroundColor: "#ffffff",
height: i.contentWindow.document.body.scrollHeight + 20,
width,
}).then((dataUrl) => {
const link = document.createElement("a");
link.download = this.message.ID + ".png";
link.href = dataUrl;
link.click();
this.loading = 0;
this.html = false;
});
},
},
};
</script>
<template>
<iframe
v-if="html"
id="screenshot-html"
:srcdoc="html"
frameborder="0"
style="position: absolute; margin-left: -100000px"
@load="doScreenshot"
>
</iframe>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1,144 +0,0 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
import { domToPng } from 'modern-screenshot'
export default {
props: {
message: Object,
},
mixins: [CommonMixins],
components: {
AjaxLoader,
},
data() {
return {
html: false,
loading: 0
}
},
methods: {
initScreenshot() {
this.loading = 1
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
// Outlook hacks - else screenshot returns blank image
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
// update any inline `url(...)` absolute links
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
let doc = document.implementation.createHTMLDocument();
doc.open()
doc.write(h)
doc.close()
// remove any <script> tags
let scripts = doc.getElementsByTagName('script')
for (let i of scripts) {
i.parentNode.removeChild(i)
}
// replace stylesheet links with proxy links
let stylesheets = doc.getElementsByTagName('link')
for (let i of stylesheets) {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// replace images with proxy links
let images = doc.getElementsByTagName('img')
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// replace background="" attributes with proxy links
let backgrounds = doc.querySelectorAll("[background]")
for (let i of backgrounds) {
let src = i.getAttribute('background')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
// set html with manipulated document content
this.html = new XMLSerializer().serializeToString(doc)
},
// HTML decode function
decodeEntities(s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
e.textContent = ''
return str
},
doScreenshot() {
let width = document.getElementById('message-view').getBoundingClientRect().width
let prev = document.getElementById('preview-html')
if (prev && prev.getBoundingClientRect().width) {
width = prev.getBoundingClientRect().width
}
if (width < 300) {
width = 300
}
const i = document.getElementById('screenshot-html')
// set the iframe width
i.style.width = width + 'px'
let body = i.contentWindow.document.querySelector('body')
// take screenshot of iframe
domToPng(body, {
backgroundColor: '#ffffff',
height: i.contentWindow.document.body.scrollHeight + 20,
width: width,
}).then(dataUrl => {
const link = document.createElement('a')
link.download = this.message.ID + '.png'
link.href = dataUrl
link.click()
this.loading = 0
this.html = false
})
}
}
}
</script>
<template>
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
style="position: absolute; margin-left: -100000px;">
</iframe>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1,52 +1,85 @@
<script>
import { VcDonut } from 'vue-css-donut-chart'
import axios from 'axios'
import commonMixins from '../../mixins/CommonMixins'
import { VcDonut } from "vue-css-donut-chart";
import axios from "axios";
import commonMixins from "../../mixins/CommonMixins";
export default {
props: {
message: Object,
},
components: {
VcDonut,
},
emits: ["setSpamScore", "setBadgeStyle"],
mixins: [commonMixins],
props: {
message: {
type: Object,
default: () => ({}),
},
},
emits: ["setSpamScore", "setBadgeStyle"],
data() {
return {
error: false,
check: false,
}
};
},
mounted() {
this.doCheck()
computed: {
graphSections() {
const score = this.check.Score;
let p = Math.round((score / 5) * 100);
if (p > 100) {
p = 100;
} else if (p < 0) {
p = 0;
}
let c = "#ffc107";
if (this.check.IsSpam) {
c = "#dc3545";
}
return [
{
label: score + " / 5",
value: p,
color: c,
},
];
},
scoreColor() {
return this.graphSections[0].color;
},
},
watch: {
message: {
handler() {
this.$emit('setSpamScore', false)
this.doCheck()
this.$emit("setSpamScore", false);
this.doCheck();
},
deep: true
deep: true,
},
},
mounted() {
this.doCheck();
},
methods: {
doCheck() {
this.check = false
this.check = false;
// ignore any error, do not show loader
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
axios
.get(this.resolve("/api/v1/message/" + this.message.ID + "/sa-check"), null)
.then((result) => {
this.check = result.data
this.error = false
this.setIcons()
this.check = result.data;
this.error = false;
this.setIcons();
})
.catch((error) => {
// handle error
@@ -54,80 +87,50 @@ 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;
}
})
});
},
badgeStyle(ignorePadding = false) {
let badgeStyle = 'bg-success'
let badgeStyle = "bg-success";
if (this.check.Error) {
badgeStyle = 'bg-warning text-primary'
}
else if (this.check.IsSpam) {
badgeStyle = 'bg-danger'
badgeStyle = "bg-warning text-primary";
} else if (this.check.IsSpam) {
badgeStyle = "bg-danger";
} else if (this.check.Score >= 4) {
badgeStyle = 'bg-warning text-primary'
badgeStyle = "bg-warning text-primary";
}
if (!ignorePadding && String(this.check.Score).includes('.')) {
badgeStyle += " p-1"
if (!ignorePadding && String(this.check.Score).includes(".")) {
badgeStyle += " p-1";
}
return badgeStyle
return badgeStyle;
},
setIcons() {
let score = this.check.Score
if (this.check.Error && this.check.Error != '') {
score = '!'
let score = this.check.Score;
if (this.check.Error && this.check.Error !== "") {
score = "!";
}
let badgeStyle = this.badgeStyle()
this.$emit('setBadgeStyle', badgeStyle)
this.$emit('setSpamScore', score)
const badgeStyle = this.badgeStyle();
this.$emit("setBadgeStyle", badgeStyle);
this.$emit("setSpamScore", score);
},
},
computed: {
graphSections() {
let score = this.check.Score
let p = Math.round(score / 5 * 100)
if (p > 100) {
p = 100
} else if (p < 0) {
p = 0
}
let c = '#ffc107'
if (this.check.IsSpam) {
c = '#dc3545'
}
return [
{
label: score + ' / 5',
value: p,
color: c
},
]
},
scoreColor() {
return this.graphSections[0].color
},
}
}
};
</script>
<template>
@@ -145,10 +148,10 @@ export default {
<template v-if="error || check.Error != ''">
<p>Your message could not be checked</p>
<div class="alert alert-warning" v-if="error">
<div v-if="error" class="alert alert-warning">
{{ error }}
</div>
<div class="alert alert-warning" v-else>
<div v-else class="alert alert-warning">
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
</div>
</template>
@@ -156,11 +159,18 @@ export default {
<template v-else-if="check">
<div class="row w-100 mt-5">
<div class="col-xl-5 mb-2">
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ check.Score }} / 5
</h2>
<vc-donut
:sections="graphSections"
background="var(--bs-body-bg)"
:size="230"
unit="px"
:thickness="20"
:total="100"
:start-angle="270"
:auto-adjust-text-size="true"
foreground="#198754"
>
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">{{ check.Score }} / 5</h2>
<div class="text-body mt-2">
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
@@ -180,7 +190,7 @@ export default {
</div>
</div>
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules">
<div v-for="r in check.Rules" :key="'rule_' + r.Name" class="row w-100 py-2 border-bottom small">
<div class="col-2 col-lg-1">
{{ r.Score }}
</div>
@@ -195,25 +205,39 @@ export default {
</div>
</template>
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
aria-hidden="true">
<div
id="AboutSpamAnalysis"
class="modal fade"
tabindex="-1"
aria-labelledby="AboutSpamAnalysisLabel"
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="AboutSpamAnalysisLabel">About Spam Analysis</h1>
<h1 id="AboutSpamAnalysisLabel" class="modal-title fs-5">About Spam Analysis</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="accordion" id="SpamAnalysisAboutAccordion">
<div id="SpamAnalysisAboutAccordion" 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 Spam Analysis?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div
id="col1"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body">
<p>
Mailpit integrates with SpamAssassin to provide you with some insight into the
@@ -226,13 +250,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 the point system work?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div
id="col2"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body">
<p>
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
@@ -248,18 +281,27 @@ 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"
>
But I don't agree with the results...
</button>
</h2>
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div
id="col3"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body">
<p>
Mailpit does not manipulate the results nor determine the "spamminess" of
your message. The result is what SpamAssassin returns, and it entirely
dependent on how SpamAssassin is set up and optionally trained.
Mailpit does not manipulate the results nor determine the "spamminess" of your
message. The result is what SpamAssassin returns, and it entirely dependent on
how SpamAssassin is set up and optionally trained.
</p>
<p>
This tool is simply provided as an aid to assist you. If you are running your
@@ -271,20 +313,31 @@ 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="#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"
>
Where can I find more information about the triggered rules?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div
id="col4"
class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion"
>
<div class="accordion-body">
<p>
Unfortunately the current <a href="https://spamassassin.apache.org/"
target="_blank">SpamAssassin website</a> no longer contains any relative
documentation about these, most likely because the rules come from different
locations and change often. You will need to search the internet for these
yourself.
Unfortunately the current
<a href="https://spamassassin.apache.org/" target="_blank"
>SpamAssassin website</a
>
no longer contains any relative documentation about these, most likely because
the rules come from different locations and change often. You will need to
search the internet for these yourself.
</p>
</div>
</div>

View File

@@ -1,19 +1,18 @@
import axios from 'axios'
import dayjs from 'dayjs'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
import axios from "axios";
import dayjs from "dayjs";
import ColorHash from "color-hash";
import { Modal, Offcanvas } from "bootstrap";
import { limitOptions } from "../stores/pagination";
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
class BootstrapElement {
constructor() { }
hide() { }
show() { }
hide() {}
show() {}
}
// Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] })
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
/* Common mixin functions used in apps */
export default {
@@ -21,89 +20,89 @@ export default {
return {
loading: 0,
tagColorCache: {},
}
};
},
methods: {
resolve(u) {
return this.$router.resolve(u).href
return this.$router.resolve(u).href;
},
searchURI(s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
return this.resolve("/search") + "?q=" + encodeURIComponent(s);
},
getFileSize(bytes) {
if (bytes == 0) {
return '0B'
if (bytes === 0) {
return "0B";
}
var i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
},
formatNumber(nr) {
return new Intl.NumberFormat().format(nr)
return new Intl.NumberFormat().format(nr);
},
messageDate(d) {
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
return dayjs(d).format("ddd, D MMM YYYY, h:mm a");
},
secondsToRelative(d) {
return dayjs().subtract(d, 'seconds').fromNow()
return dayjs().subtract(d, "seconds").fromNow();
},
tagEncodeURI(tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
tag = `"${tag}"`;
}
return encodeURIComponent(`tag:${tag}`)
return encodeURIComponent(`tag:${tag}`);
},
getSearch() {
if (!window.location.search) {
return false
return false;
}
const urlParams = new URLSearchParams(window.location.search)
const q = urlParams.get('q')?.trim()
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get("q")?.trim();
if (!q) {
return false
return false;
}
return q
return q;
},
getPaginationParams() {
if (!window.location.search) {
return null
return null;
}
const urlParams = new URLSearchParams(window.location.search)
const start = parseInt(urlParams.get('start')?.trim(), 10)
const limit = parseInt(urlParams.get('limit')?.trim(), 10)
const urlParams = new URLSearchParams(window.location.search);
const start = parseInt(urlParams.get("start")?.trim(), 10);
const limit = parseInt(urlParams.get("limit")?.trim(), 10);
return {
start: Number.isInteger(start) && start >= 0 ? start : null,
limit: limitOptions.includes(limit) ? limit : null,
}
};
},
// generic modal get/set function
modal(id) {
const e = document.getElementById(id)
const e = document.getElementById(id);
if (e) {
return Modal.getOrCreateInstance(e)
return Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new BootstrapElement()
return new BootstrapElement();
},
// close mobile navigation
hideNav() {
const e = document.getElementById('offcanvas')
const e = document.getElementById("offcanvas");
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
Offcanvas.getOrCreateInstance(e).hide();
}
},
@@ -117,23 +116,24 @@ export default {
*/
get(url, values, callback, errorCallback, hideLoader) {
if (!hideLoader) {
this.loading++
this.loading++;
}
axios.get(url, { params: values })
axios
.get(url, { params: values })
.then(callback)
.catch((err) => {
if (typeof errorCallback == 'function') {
return errorCallback(err)
if (typeof errorCallback === "function") {
return errorCallback(err);
}
this.handleError(err)
this.handleError(err);
})
.then(() => {
// always executed
if (!hideLoader && this.loading > 0) {
this.loading--
this.loading--;
}
})
});
},
/**
@@ -144,16 +144,17 @@ export default {
* @params function callback function
*/
post(url, data, callback) {
this.loading++
axios.post(url, data)
this.loading++;
axios
.post(url, data)
.then(callback)
.catch(this.handleError)
.then(() => {
// always executed
if (this.loading > 0) {
this.loading--
this.loading--;
}
})
});
},
/**
@@ -164,16 +165,17 @@ export default {
* @params function callback function
*/
delete(url, data, callback) {
this.loading++
axios.delete(url, { data: data })
this.loading++;
axios
.delete(url, { data })
.then(callback)
.catch(this.handleError)
.then(() => {
// always executed
if (this.loading > 0) {
this.loading--
this.loading--;
}
})
});
},
/**
@@ -184,16 +186,17 @@ export default {
* @params function callback function
*/
put(url, data, callback) {
this.loading++
axios.put(url, data)
this.loading++;
axios
.put(url, data)
.then(callback)
.catch(this.handleError)
.then(() => {
// always executed
if (this.loading > 0) {
this.loading--
this.loading--;
}
})
});
},
// Ajax error message
@@ -203,87 +206,87 @@ 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) {
alert(error.response.data.Error)
alert(error.response.data.Error);
} else {
alert(error.response.data)
alert(error.response.data);
}
} else if (error.request) {
// The request was made but no response was received
alert('Error sending data to the server. Please try again.')
alert("Error sending data to the server. Please try again.");
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message)
alert(error.message);
}
},
allAttachments(message) {
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i])
const a = [];
for (const i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i])
for (const i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i])
for (const i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false
return a.length ? a : false;
},
isImage(a) {
return a.ContentType.match(/^image\//)
return a.ContentType.match(/^image\//);
},
attachmentIcon(a) {
let ext = a.FileName.split('.').pop().toLowerCase()
const ext = a.FileName.split(".").pop().toLowerCase();
if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill'
return "bi-file-image-fill";
}
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
return 'bi-file-pdf-fill'
if (a.ContentType.match(/\/pdf$/) || ext === "pdf") {
return "bi-file-pdf-fill";
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill'
if (["doc", "docx", "odt", "rtf"].includes(ext)) {
return "bi-file-word-fill";
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill'
if (["xls", "xlsx", "ods"].includes(ext)) {
return "bi-file-spreadsheet-fill";
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill'
if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) {
return "bi-file-slides-fill";
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill'
if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) {
return "bi-file-zip-fill";
}
if (['ics'].includes(ext)) {
return 'bi-calendar-event'
if (["ics"].includes(ext)) {
return "bi-calendar-event";
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill'
return "bi-file-music-fill";
}
if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill'
return "bi-file-play-fill";
}
if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill'
return "bi-file-check-fill";
}
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
return 'bi-file-text-fill'
if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) {
return "bi-file-text-fill";
}
return 'bi-file-arrow-down-fill'
return "bi-file-arrow-down-fill";
},
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash(s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
if (this.tagColorCache[s] !== undefined) {
return this.tagColorCache[s];
}
this.tagColorCache[s] = colorHash.hex(s)
this.tagColorCache[s] = colorHash.hex(s);
return this.tagColorCache[s]
return this.tagColorCache[s];
},
}
}
},
};

View File

@@ -1,6 +1,6 @@
import CommonMixins from './CommonMixins.js'
import { mailbox } from '../stores/mailbox.js'
import { pagination } from '../stores/pagination.js'
import CommonMixins from "./CommonMixins.js";
import { mailbox } from "../stores/mailbox.js";
import { pagination } from "../stores/pagination.js";
export default {
mixins: [CommonMixins],
@@ -10,88 +10,86 @@ export default {
apiURI: false,
pagination,
mailbox,
}
};
},
watch: {
'mailbox.refresh': function (v) {
"mailbox.refresh": function (v) {
if (v) {
// trigger a refresh
this.loadMessages()
this.loadMessages();
}
mailbox.refresh = false
}
mailbox.refresh = false;
},
},
methods: {
reloadMailbox() {
pagination.start = 0
this.loadMessages()
pagination.start = 0;
this.loadMessages();
},
loadMessages() {
if (!this.apiURI) {
alert('apiURL not set!')
return
alert("apiURL not set!");
return;
}
// auto-pagination changes the URL but should not fetch new messages
// when viewing page > 0 and new messages are received (inbox only)
if (!mailbox.autoPaginating) {
mailbox.autoPaginating = true // reset
return
mailbox.autoPaginating = true; // reset
return;
}
const params = {}
mailbox.selected = []
const params = {};
mailbox.selected = [];
params['limit'] = pagination.limit
params["limit"] = pagination.limit;
if (pagination.start > 0) {
params['start'] = pagination.start
params["start"] = pagination.start;
}
this.get(this.apiURI, params, (response) => {
mailbox.total = response.data.total // all messages
mailbox.unread = response.data.unread // all unread messages
mailbox.tags = response.data.tags // all tags
mailbox.messages = response.data.messages // current messages
mailbox.count = response.data.messages_count // total results for this mailbox/search
mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search
mailbox.total = response.data.total; // all messages
mailbox.unread = response.data.unread; // all unread messages
mailbox.tags = response.data.tags; // all tags
mailbox.messages = response.data.messages; // current messages
mailbox.count = response.data.messages_count; // total results for this mailbox/search
mailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search
// ensure the pagination remains consistent
pagination.start = response.data.start
pagination.start = response.data.start;
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return this.loadMessages()
if (response.data.count === 0 && response.data.start > 0) {
pagination.start = 0;
return this.loadMessages();
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
const m = document.getElementById(mailbox.lastMessage)
const m = document.getElementById(mailbox.lastMessage);
if (m) {
m.focus()
m.focus();
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
m.scrollIntoView({ block: "center" });
} else {
const mp = document.getElementById('message-page')
const mp = document.getElementById("message-page");
if (mp) {
mp.scrollTop = 0
mp.scrollTop = 0;
}
}
mailbox.lastMessage = false
}, 50)
mailbox.lastMessage = false;
}, 50);
} else if (!window.scrollInPlace) {
const mp = document.getElementById('message-page')
const mp = document.getElementById("message-page");
if (mp) {
mp.scrollTop = 0
mp.scrollTop = 0;
}
}
window.scrollInPlace = false
})
window.scrollInPlace = false;
});
},
}
}
},
};

View File

@@ -1,13 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import MailboxView from '../views/MailboxView.vue'
import MessageView from '../views/MessageView.vue'
import NotFoundView from '../views/NotFoundView.vue'
import SearchView from '../views/SearchView.vue'
import { createRouter, createWebHistory } from "vue-router";
import MailboxView from "../views/MailboxView.vue";
import MessageView from "../views/MessageView.vue";
import NotFoundView from "../views/NotFoundView.vue";
import SearchView from "../views/SearchView.vue";
let d = document.getElementById('app')
let webroot = '/'
const d = document.getElementById("app");
let webroot = "/";
if (d) {
webroot = d.dataset.webroot
webroot = d.dataset.webroot;
}
// paths are relative to webroot
@@ -15,23 +15,23 @@ const router = createRouter({
history: createWebHistory(webroot),
routes: [
{
path: '/',
component: MailboxView
path: "/",
component: MailboxView,
},
{
path: '/search',
component: SearchView
path: "/search",
component: SearchView,
},
{
path: '/view/:id',
component: MessageView
path: "/view/:id",
component: MessageView,
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFoundView
}
]
})
path: "/:pathMatch(.*)*",
name: "NotFound",
component: NotFoundView,
},
],
});
export default router
export default router;

View File

@@ -1,92 +1,94 @@
// State Management
import { reactive, watch } from 'vue'
import { reactive, watch } from "vue";
// global mailbox info
export const mailbox = reactive({
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
notificationsSupported: false, // browser supports notifications
notificationsEnabled: false, // user has enabled notifications
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
notificationsSupported: false, // browser supports notifications
notificationsEnabled: false, // user has enabled notifications
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
// settings
showTagColors: !localStorage.getItem('hideTagColors') == '1',
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1',
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1',
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1',
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone,
})
showTagColors: !localStorage.getItem("hideTagColors"),
showHTMLCheck: !localStorage.getItem("hideHTMLCheck"),
showLinkCheck: !localStorage.getItem("hideLinkCheck"),
showSpamCheck: !localStorage.getItem("hideSpamCheck"),
timeZone: localStorage.getItem("timeZone")
? localStorage.getItem("timeZone")
: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
watch(
() => mailbox.count,
(v) => {
mailbox.selected = []
}
)
mailbox.selected = [];
},
);
watch(
() => mailbox.showTagColors,
(v) => {
if (v) {
localStorage.removeItem('hideTagColors')
localStorage.removeItem("hideTagColors");
} else {
localStorage.setItem('hideTagColors', '1')
localStorage.setItem("hideTagColors", "1");
}
}
)
},
);
watch(
() => mailbox.showHTMLCheck,
(v) => {
if (v) {
localStorage.removeItem('hideHTMLCheck')
localStorage.removeItem("hideHTMLCheck");
} else {
localStorage.setItem('hideHTMLCheck', '1')
localStorage.setItem("hideHTMLCheck", "1");
}
}
)
},
);
watch(
() => mailbox.showLinkCheck,
(v) => {
if (v) {
localStorage.removeItem('hideLinkCheck')
localStorage.removeItem("hideLinkCheck");
} else {
localStorage.setItem('hideLinkCheck', '1')
localStorage.setItem("hideLinkCheck", "1");
}
}
)
},
);
watch(
() => mailbox.showSpamCheck,
(v) => {
if (v) {
localStorage.removeItem('hideSpamCheck')
localStorage.removeItem("hideSpamCheck");
} else {
localStorage.setItem('hideSpamCheck', '1')
localStorage.setItem("hideSpamCheck", "1");
}
}
)
},
);
watch(
() => mailbox.timeZone,
(v) => {
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) {
localStorage.removeItem('timeZone')
if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) {
localStorage.removeItem("timeZone");
} else {
localStorage.setItem('timeZone', v)
localStorage.setItem("timeZone", v);
}
}
)
},
);

View File

@@ -1,11 +1,11 @@
import { reactive } from 'vue'
import { reactive } from "vue";
export const pagination = reactive({
start: 0, // pagination offset
limit: 50, // per page
start: 0, // pagination offset
limit: 50, // per page
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed
})
total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed
});
export const limitOptions = [25, 50, 100, 200]
export const limitOptions = [25, 50, 100, 200];

View File

@@ -1,24 +1,19 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import ListMessages from '../components/ListMessages.vue'
import MessagesMixins from '../mixins/MessagesMixins'
import NavMailbox from '../components/NavMailbox.vue'
import NavTags from '../components/NavTags.vue'
import Pagination from '../components/Pagination.vue'
import SearchForm from '../components/SearchForm.vue'
import { mailbox } from '../stores/mailbox'
import About from "../components/AppAbout.vue";
import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from "../mixins/CommonMixins";
import ListMessages from "../components/ListMessages.vue";
import MessagesMixins from "../mixins/MessagesMixins";
import NavMailbox from "../components/NavMailbox.vue";
import NavTags from "../components/NavTags.vue";
import Pagination from "../components/NavPagination.vue";
import SearchForm from "../components/SearchForm.vue";
import { mailbox } from "../stores/mailbox";
import { pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
About,
AjaxLoader,
ListMessages,
NavMailbox,
@@ -27,111 +22,119 @@ export default {
SearchForm,
},
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
mailbox,
delayedRefresh: false,
paginationDelayed: false, // for delayed pagination URL changes
}
};
},
watch: {
$route(to, from) {
this.loadMailbox()
}
this.loadMailbox();
},
},
mounted() {
mailbox.searching = false
this.apiURI = this.resolve(`/api/v1/messages`)
this.loadMailbox()
mailbox.searching = false;
this.apiURI = this.resolve(`/api/v1/messages`);
this.loadMailbox();
// subscribe to events
this.eventBus.on("new", this.handleWSNew)
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
this.eventBus.on("new", this.handleWSNew);
this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate);
},
unmounted() {
// unsubscribe from events
this.eventBus.off("new", this.handleWSNew)
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
this.eventBus.off("new", this.handleWSNew);
this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate);
},
methods: {
loadMailbox() {
const paginationParams = this.getPaginationParams()
const paginationParams = this.getPaginationParams();
if (paginationParams?.start) {
pagination.start = paginationParams.start
pagination.start = paginationParams.start;
} else {
pagination.start = 0
pagination.start = 0;
}
if (paginationParams?.limit) {
pagination.limit = paginationParams.limit
pagination.limit = paginationParams.limit;
}
this.loadMessages()
this.loadMessages();
},
// This will only update the pagination offset at a maximum of 2x per second
// when viewing the inbox on > page 1, while receiving an influx of new messages.
delayedPaginationUpdate() {
if (this.paginationDelayed) {
return
return;
}
this.paginationDelayed = true
this.paginationDelayed = true;
window.setTimeout(() => {
const path = this.$route.path
const path = this.$route.path;
const p = {
...this.$route.query
}
...this.$route.query,
};
if (pagination.start > 0) {
p.start = pagination.start.toString()
p.start = pagination.start.toString();
} else {
delete p.start
delete p.start;
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
} else {
delete p.limit
delete p.limit;
}
mailbox.autoPaginating = false // prevent reload of messages when URL changes
const params = new URLSearchParams(p)
this.$router.replace(path + '?' + params.toString())
mailbox.autoPaginating = false; // prevent reload of messages when URL changes
const params = new URLSearchParams(p);
this.$router.replace(path + "?" + params.toString());
this.paginationDelayed = false
}, 500)
this.paginationDelayed = false;
}, 500);
},
// handler for websocket new messages
handleWSNew(data) {
if (pagination.start < 1) {
// push results directly into first page
mailbox.messages.unshift(data)
mailbox.messages.unshift(data);
if (mailbox.messages.length > pagination.limit) {
mailbox.messages.pop()
mailbox.messages.pop();
}
} else {
// update pagination offset
pagination.start++
pagination.start++;
// prevent "Too many calls to Location or History APIs within a short time frame"
this.delayedPaginationUpdate()
this.delayedPaginationUpdate();
}
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
if (this.mailbox.messages[x].ID === data.ID) {
// update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
return
this.mailbox.messages[x] = {
...this.mailbox.messages[x],
...data,
};
return;
}
}
},
@@ -140,43 +143,43 @@ export default {
handleWSDelete(data) {
let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
if (this.mailbox.messages[x].ID === data.ID) {
// remove message from the list
this.mailbox.messages.splice(x, 1)
removed++
continue
this.mailbox.messages.splice(x, 1);
removed++;
continue;
}
}
if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued,
// don't refresh
return
return;
}
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true
this.delayedRefresh = true;
window.setTimeout(() => {
this.delayedRefresh = false
this.loadMessages()
}, 500)
this.delayedRefresh = false;
this.loadMessages();
}, 500);
},
// handler for websocket message truncation
handleWSTruncate() {
// all messages gone, reload
this.loadMessages()
this.loadMessages();
},
}
}
},
};
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="col-xl-2 col-md-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink>
</div>
@@ -185,8 +188,13 @@ export default {
</div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0">
<div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvas" aria-controls="offcanvas">
<button
class="btn btn-outline-light me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvas"
aria-controls="offcanvas"
>
<i class="bi bi-list"></i>
</button>
</div>
@@ -194,42 +202,51 @@ export default {
</div>
</div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
aria-labelledby="offcanvasLabel">
<div
id="offcanvas"
class="offcanvas-md offcanvas-start d-md-none"
data-bs-scroll="true"
tabindex="-1"
aria-labelledby="offcanvasLabel"
>
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
aria-label="Close"></button>
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target="#offcanvas"
aria-label="Close"
></button>
</div>
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<NavMailbox @loadMessages="loadMessages" />
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @load-messages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
<About />
</div>
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="row flex-fill" style="min-height: 0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<NavMailbox @loadMessages="loadMessages" />
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavMailbox @load-messages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
<About />
</div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<div id="message-page" class="mh-100" style="overflow-y: auto">
<ListMessages :loading-messages="loading" />
</div>
</div>
</div>
<NavMailbox @loadMessages="loadMessages" modals />
<AboutMailpit modals />
<NavMailbox modals @load-messages="loadMessages" />
<About modals />
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1,20 +1,15 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import Message from '../components/message/Message.vue'
import Release from '../components/message/Release.vue'
import Screenshot from '../components/message/Screenshot.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
import dayjs from 'dayjs'
import AboutMailpit from "../components/AppAbout.vue";
import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from "../mixins/CommonMixins";
import Message from "../components/message/MessageItem.vue";
import Release from "../components/message/MessageRelease.vue";
import Screenshot from "../components/message/MessageScreenshot.vue";
import { mailbox } from "../stores/mailbox";
import { pagination } from "../stores/pagination";
import dayjs from "dayjs";
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
AjaxLoader,
@@ -23,6 +18,11 @@ export default {
Release,
},
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
mailbox,
@@ -36,203 +36,206 @@ export default {
liveLoaded: 0, // the number new messages prepended tp messageList
scrollLoading: false,
canLoadMore: true,
}
},
watch: {
$route(to, from) {
this.loadMessage()
},
},
created() {
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.initLoadMoreAPIParams()
},
mounted() {
this.loadMessage()
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
if (!this.messagesList.length) {
this.loadMore()
}
this.refreshUI()
// subscribe to events
this.eventBus.on("new", this.handleWSNew)
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
},
unmounted() {
// unsubscribe from events
this.eventBus.off("new", this.handleWSNew)
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
};
},
computed: {
// get current message read status
isRead() {
const l = this.messagesList.length
const l = this.messagesList.length;
if (!this.message || !l) {
return true
return true;
}
let id = false
for (x = 0; x < l; x++) {
if (this.messagesList[x].ID == this.message.ID) {
return this.messagesList[x].Read
for (let x = 0; x < l; x++) {
if (this.messagesList[x].ID === this.message.ID) {
return this.messagesList[x].Read;
}
}
return true
return true;
},
// get the previous message ID
previousID() {
const l = this.messagesList.length
const l = this.messagesList.length;
if (!this.message || !l) {
return false
return false;
}
let id = false
for (x = 0; x < l; x++) {
if (this.messagesList[x].ID == this.message.ID) {
return id
let id = false;
for (let x = 0; x < l; x++) {
if (this.messagesList[x].ID === this.message.ID) {
return id;
}
id = this.messagesList[x].ID
id = this.messagesList[x].ID;
}
return false
return false;
},
// get the next message ID
nextID() {
const l = this.messagesList.length
const l = this.messagesList.length;
if (!this.message || !l) {
return false
return false;
}
let id = false
for (x = l - 1; x > 0; x--) {
if (this.messagesList[x].ID == this.message.ID) {
return id
let id = false;
for (let x = l - 1; x > 0; x--) {
if (this.messagesList[x].ID === this.message.ID) {
return id;
}
id = this.messagesList[x].ID
id = this.messagesList[x].ID;
}
return id
return id;
},
},
watch: {
$route(to, from) {
this.loadMessage();
},
},
created() {
const relativeTime = require("dayjs/plugin/relativeTime");
dayjs.extend(relativeTime);
this.initLoadMoreAPIParams();
},
mounted() {
this.loadMessage();
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages));
if (!this.messagesList.length) {
this.loadMore();
}
this.refreshUI();
// subscribe to events
this.eventBus.on("new", this.handleWSNew);
this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate);
},
unmounted() {
// unsubscribe from events
this.eventBus.off("new", this.handleWSNew);
this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate);
},
methods: {
loadMessage() {
this.message = false
const uri = this.resolve('/api/v1/message/' + this.$route.params.id)
this.get(uri, false, (response) => {
this.errorMessage = false
const d = response.data
this.message = false;
const uri = this.resolve("/api/v1/message/" + this.$route.params.id);
this.get(
uri,
false,
(response) => {
this.errorMessage = false;
const d = response.data;
// update read status in case websockets is not working
this.handleWSUpdate({ 'ID': d.ID, Read: true })
// update read status in case websockets is not working
this.handleWSUpdate({ ID: d.ID, Read: true });
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
for (let i in d.Inline) {
let a = d.Inline[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
for (const i in d.Inline) {
const a = d.Inline[i];
if (a.ContentID !== "") {
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}
if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}
}
}
}
// replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) {
for (let i in d.Attachments) {
let a = d.Attachments[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
// replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) {
for (const i in d.Attachments) {
const a = d.Attachments[i];
if (a.ContentID !== "") {
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(cid:" + a.ContentID + ")([\"|'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}
if (a.FileName.match(/^[a-zA-Z0-9_\-.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp("(=[\"']?)(" + a.FileName + ")([\"|'|\\s|\\/|>|;])", "g"),
"$1" + this.resolve("/api/v1/message/" + d.ID + "/part/" + a.PartID) + "$3",
);
}
}
}
}
this.message = d
this.message = d;
this.$nextTick(() => {
this.scrollSidebarToCurrent()
})
},
this.$nextTick(() => {
this.scrollSidebarToCurrent();
});
},
(error) => {
this.errorMessage = true
this.errorMessage = true;
if (error.response && error.response.data) {
if (error.response.data.Error) {
this.errorMessage = error.response.data.Error
this.errorMessage = error.response.data.Error;
} else {
this.errorMessage = error.response.data
this.errorMessage = error.response.data;
}
} else if (error.request) {
// The request was made but no response was received
this.errorMessage = 'Error sending data to the server. Please refresh the page.'
this.errorMessage = "Error sending data to the server. Please refresh the page.";
} else {
// Something happened in setting up the request that triggered an Error
this.errorMessage = error.message
this.errorMessage = error.message;
}
})
},
);
},
// UI refresh ticker to adjust relative times
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
this.$forceUpdate();
this.refreshUI();
}, 30000);
},
// handler for websocket new messages
handleWSNew(data) {
// do not add when searching or >= 100 new messages have been received
if (this.mailbox.searching || this.liveLoaded >= 100) {
return
return;
}
this.liveLoaded++
this.messagesList.unshift(data)
this.liveLoaded++;
this.messagesList.unshift(data);
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) {
if (this.messagesList[x].ID === data.ID) {
// update message
this.messagesList[x] = { ...this.messagesList[x], ...data }
return
this.messagesList[x] = { ...this.messagesList[x], ...data };
return;
}
}
},
@@ -240,10 +243,10 @@ export default {
// handler for websocket message deletion
handleWSDelete(data) {
for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) {
if (this.messagesList[x].ID === data.ID) {
// remove message from the list
this.messagesList.splice(x, 1)
return
this.messagesList.splice(x, 1);
return;
}
}
},
@@ -251,277 +254,299 @@ export default {
// handler for websocket message truncation
handleWSTruncate() {
// all messages gone, go to inbox
this.$router.push('/')
this.$router.push("/");
},
// return whether the sidebar is visible
sidebarVisible() {
return this.$refs.MessageList.offsetParent != null
return this.$refs.MessageList.offsetParent !== null;
},
// scroll sidenav to current message if found
scrollSidebarToCurrent() {
const cont = document.getElementById('MessageList')
const cont = document.getElementById("MessageList");
if (!cont) {
return
return;
}
const c = cont.querySelector('.router-link-active')
const c = cont.querySelector(".router-link-active");
if (c) {
const outer = cont.getBoundingClientRect()
const li = c.getBoundingClientRect()
const outer = cont.getBoundingClientRect();
const li = c.getBoundingClientRect();
if (outer.top > li.top || outer.bottom < li.bottom) {
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
c.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
}
}
},
scrollHandler(e) {
if (!this.canLoadMore || this.scrollLoading) {
return
return;
}
const { scrollTop, offsetHeight, scrollHeight } = e.target
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
this.loadMore()
const { scrollTop, offsetHeight, scrollHeight } = e.target;
if (scrollTop + offsetHeight + 150 >= scrollHeight) {
this.loadMore();
}
},
loadMore() {
if (this.messagesList.length) {
// get last created timestamp
const oldest = this.messagesList[this.messagesList.length - 1].Created
// if set append `before=<ts>`
this.apiSideNavParams.set('before', oldest)
const oldest = this.messagesList[this.messagesList.length - 1].Created;
// if set append `before=<ts>`
this.apiSideNavParams.set("before", oldest);
}
this.scrollLoading = true
this.scrollLoading = true;
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
if (response.data.messages.length) {
this.messagesList.push(...response.data.messages)
} else {
this.canLoadMore = false
}
this.$nextTick(() => {
this.scrollLoading = false
})
}, null, true)
this.get(
this.apiSideNavURI,
this.apiSideNavParams,
(response) => {
if (response.data.messages.length) {
this.messagesList.push(...response.data.messages);
} else {
this.canLoadMore = false;
}
this.$nextTick(() => {
this.scrollLoading = false;
});
},
null,
true,
);
},
initLoadMoreAPIParams() {
let apiURI = this.resolve(`/api/v1/messages`)
let p = {}
let apiURI = this.resolve(`/api/v1/messages`);
const p = {};
if (mailbox.searching) {
apiURI = this.resolve(`/api/v1/search`)
p.query = mailbox.searching
apiURI = this.resolve(`/api/v1/search`);
p.query = mailbox.searching;
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
if (pagination.limit !== pagination.defaultLimit) {
p.limit = pagination.limit.toString();
}
this.apiSideNavURI = apiURI
this.apiSideNavURI = apiURI;
this.apiSideNavParams = new URLSearchParams(p)
this.apiSideNavParams = new URLSearchParams(p);
},
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 ]";
},
isActive(id) {
return this.message.ID == id
return this.message.ID === id;
},
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();
},
downloadMessageBody(str, ext) {
const dl = document.createElement('a')
dl.href = "data:text/plain," + encodeURIComponent(str)
dl.target = '_blank'
dl.download = this.message.ID + '.' + ext
dl.click()
const dl = document.createElement("a");
dl.href = "data:text/plain," + encodeURIComponent(str);
dl.target = "_blank";
dl.download = this.message.ID + "." + ext;
dl.click();
},
screenshotMessageHTML() {
this.$refs.ScreenshotRef.initScreenshot()
this.$refs.ScreenshotRef.initScreenshot();
},
// toggle current message read status
toggleRead() {
if (!this.message) {
return false
return false;
}
const read = !this.isRead
const read = !this.isRead;
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
const ids = [this.message.ID];
const uri = this.resolve("/api/v1/messages");
this.put(uri, { Read: read, IDs: ids }, () => {
if (!this.sidebarVisible()) {
return this.goBack()
return this.goBack();
}
// manually update read status in case websockets is not working
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
})
this.handleWSUpdate({ ID: this.message.ID, Read: read });
});
},
deleteMessage() {
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
const ids = [this.message.ID];
const uri = this.resolve("/api/v1/messages");
// calculate next ID before deletion to prevent WS race
const goToID = this.nextID ? this.nextID : this.previousID
const goToID = this.nextID ? this.nextID : this.previousID;
this.delete(uri, { 'IDs': ids }, () => {
this.delete(uri, { IDs: ids }, () => {
if (!this.sidebarVisible()) {
return this.goBack()
return this.goBack();
}
if (goToID) {
return this.$router.push('/view/' + goToID)
return this.$router.push("/view/" + goToID);
}
return this.goBack()
})
return this.goBack();
});
},
// return to mailbox or search based on origin
goBack() {
mailbox.lastMessage = this.$route.params.id
mailbox.lastMessage = this.$route.params.id;
if (mailbox.searching) {
const p = {
q: mailbox.searching
}
q: mailbox.searching,
};
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();
}
this.$router.push('/search?' + new URLSearchParams(p).toString())
this.$router.push("/search?" + new URLSearchParams(p).toString());
} else {
const p = {}
const p = {};
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();
}
this.$router.push('/?' + new URLSearchParams(p).toString())
this.$router.push("/?" + new URLSearchParams(p).toString());
}
},
reloadWindow() {
location.reload()
location.reload();
},
initReleaseModal() {
this.modal('ReleaseModal').show()
this.modal("ReleaseModal").show();
window.setTimeout(() => {
// delay to allow elements to load / focus
this.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
this.$refs.ReleaseRef.initTags();
document.querySelector('#ReleaseModal input[role="combobox"]').focus();
}, 500);
},
}
}
},
};
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink>
</div>
<div class="col col-xl-5" v-if="!errorMessage">
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
<div v-if="!errorMessage" class="col col-xl-5">
<button class="btn btn-outline-light me-3 d-xl-none" title="Return to messages" @click="goBack()">
<i class="bi bi-arrow-return-left"></i>
<span class="ms-2 d-none d-lg-inline">Back</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" @click="toggleRead()">
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
<button
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
v-on:click="initReleaseModal()">
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
class="btn btn-outline-light me-1 me-sm-2"
title="Release message"
@click="initReleaseModal()"
>
<i class="bi bi-send"></i>
<span class="d-none d-md-inline">Release</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" @click="deleteMessage()">
<i class="bi bi-trash-fill"></i>
<span class="d-none d-md-inline">Delete</span>
</button>
</div>
<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage">
<div class="dropdown d-inline-block" id="DownloadBtn">
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<div v-if="!errorMessage" class="col-auto col-lg-4 col-xl-4 text-end">
<div id="DownloadBtn" class="dropdown d-inline-block">
<button
type="button"
class="btn btn-outline-light dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="bi bi-file-arrow-down-fill"></i>
<span class="d-none d-md-inline ms-1">Download</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item"
title="Message source including headers, body and attachments">
<a
:href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')"
class="dropdown-item"
title="Message source including headers, body and attachments"
>
Raw message
</a>
</li>
<li v-if="message.HTML">
<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item">
<button class="dropdown-item" @click="downloadMessageBody(message.HTML, 'html')">
HTML body
</button>
</li>
<li v-if="message.HTML">
<button class="dropdown-item" @click="screenshotMessageHTML()">
HTML screenshot
</button>
<button class="dropdown-item" @click="screenshotMessageHTML()">HTML screenshot</button>
</li>
<li v-if="message.Text">
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
<button class="dropdown-item" @click="downloadMessageBody(message.Text, 'txt')">
Text body
</button>
</li>
<template v-if="message.Attachments && message.Attachments.length">
<li>
<hr class="dropdown-divider">
<hr class="dropdown-divider" />
</li>
<li>
<h6 class="dropdown-header">
Attachments
</h6>
<h6 class="dropdown-header">Attachments</h6>
</li>
<li v-for="part in message.Attachments">
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
class="row m-0 dropdown-item d-flex" target="_blank"
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
<li v-for="part in message.Attachments" :key="part.PartID">
<RouterLink
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
class="row m-0 dropdown-item d-flex"
target="_blank"
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
style="min-width: 350px"
>
<div class="col-auto p-0 pe-1">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="col text-truncate p-0 pe-1">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
</div>
<div class="col-auto text-muted small p-0">
{{ getFileSize(part.Size) }}
@@ -531,22 +556,24 @@ export default {
</template>
<template v-if="message.Inline && message.Inline.length">
<li>
<hr class="dropdown-divider">
<hr class="dropdown-divider" />
</li>
<li>
<h6 class="dropdown-header">
Inline image<span v-if="message.Inline.length > 1">s</span>
</h6>
<h6 class="dropdown-header">Inline image<span v-if="message.Inline.length > 1">s</span></h6>
</li>
<li v-for="part in message.Inline">
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
class="row m-0 dropdown-item d-flex" target="_blank"
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
<li v-for="part in message.Inline" :key="part.PartID">
<RouterLink
:to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
class="row m-0 dropdown-item d-flex"
target="_blank"
:title="part.FileName !== '' ? part.FileName : '[ unknown ]'"
style="min-width: 350px"
>
<div class="col-auto p-0 pe-1">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="col text-truncate p-0 pe-1">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
{{ part.FileName !== "" ? part.FileName : "[ unknown ]" }}
</div>
<div class="col-auto text-muted small p-0">
{{ getFileSize(part.Size) }}
@@ -557,8 +584,12 @@ export default {
</ul>
</div>
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
:class="previousID ? '' : 'disabled'" title="View previous message">
<RouterLink
:to="'/view/' + previousID"
class="btn btn-outline-light ms-1 ms-sm-2 me-1"
:class="previousID ? '' : 'disabled'"
title="View previous message"
>
<i class="bi bi-caret-left-fill"></i>
</RouterLink>
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
@@ -567,69 +598,89 @@ export default {
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="row flex-fill" style="min-height: 0">
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
<div 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="goBack()" class="list-group-item list-group-item-action">
<button class="list-group-item list-group-item-action" @click="goBack()">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">
Return to
<template v-if="mailbox.searching">search</template>
<template v-else>inbox</template>
</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread && !errorMessage">
<span
v-if="mailbox.unread && !errorMessage"
class="badge rounded-pill ms-1 float-end text-bg-secondary"
title="Unread messages"
>
{{ formatNumber(mailbox.unread) }}
</span>
</button>
</div>
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
@scroll="scrollHandler">
<div
id="MessageList"
ref="MessageList"
class="flex-grow-1 overflow-y-auto px-1 me-n1"
@scroll="scrollHandler"
>
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
Reload to see newer messages
</button>
<template v-if="messagesList && messagesList.length">
<div class="list-group">
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
:id="message.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
<RouterLink
v-for="summary in messagesList"
:id="summary.ID"
:key="'summary_' + summary.ID"
:to="'/view/' + summary.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action message"
:class="[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']"
>
<div class="col overflow-x-hidden">
<div class="text-truncate privacy small">
<strong v-if="message.From" :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
<strong v-if="summary.From" :title="'From: ' + summary.From.Address">
{{ summary.From.Name ? summary.From.Name : summary.From.Address }}
</strong>
</div>
</div>
<div class="col-auto small">
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
<i v-if="summary.Attachments" class="bi bi-paperclip h6"></i>
{{ getRelativeCreated(summary) }}
</div>
<div class="col-12 overflow-x-hidden">
<div class="text-truncate privacy small">
To: {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
To: {{ getPrimaryEmailTo(summary) }}
<span v-if="summary.To && summary.To.length > 1">
[+{{ summary.To.length - 1 }}]
</span>
</div>
</div>
<div class="col-12 overflow-x-hidden mt-1">
<div class="text-truncates small">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
<b>{{ summary.Subject !== "" ? summary.Subject : "[ no subject ]" }}</b>
</div>
</div>
<div v-if="message.Tags.length" class="col-12">
<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">
<div v-if="summary.Tags.length" class="col-12">
<RouterLink
v-for="t in summary.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>
@@ -642,7 +693,7 @@ export default {
</div>
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<div id="message-page" class="mh-100" style="overflow-y: auto">
<template v-if="errorMessage">
<h3 class="text-center my-3">
{{ errorMessage }}
@@ -655,7 +706,11 @@ export default {
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message"
@delete="deleteMessage" />
<Release
v-if="mailbox.uiConfig.MessageRelay && message"
ref="ReleaseRef"
:message="message"
@delete="deleteMessage"
/>
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
</template>

View File

@@ -1,21 +1,21 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import CommonMixins from '../mixins/CommonMixins'
import About from "../components/AppAbout.vue";
import CommonMixins from "../mixins/CommonMixins";
export default {
mixins: [CommonMixins],
components: {
AboutMailpit,
About,
},
}
mixins: [CommonMixins],
};
</script>
<template>
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
<div class="d-block text-center">
<RouterLink to="/" class="text-white">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width: 80%; width: 100px" />
<p class="h2 my-3">Page not found</p>
<p>Click here to continue</p>
@@ -23,7 +23,7 @@ export default {
</div>
<div class="d-none">
<AboutMailpit />
<About />
</div>
</div>
</template>

View File

@@ -1,24 +1,19 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import ListMessages from '../components/ListMessages.vue'
import MessagesMixins from '../mixins/MessagesMixins'
import NavSearch from '../components/NavSearch.vue'
import NavTags from '../components/NavTags.vue'
import Pagination from '../components/Pagination.vue'
import SearchForm from '../components/SearchForm.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
import About from "../components/AppAbout.vue";
import AjaxLoader from "../components/AjaxLoader.vue";
import CommonMixins from "../mixins/CommonMixins";
import ListMessages from "../components/ListMessages.vue";
import MessagesMixins from "../mixins/MessagesMixins";
import NavSearch from "../components/NavSearch.vue";
import NavTags from "../components/NavTags.vue";
import Pagination from "../components/NavPagination.vue";
import SearchForm from "../components/SearchForm.vue";
import { mailbox } from "../stores/mailbox";
import { pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
About,
AjaxLoader,
ListMessages,
NavSearch,
@@ -27,63 +22,68 @@ export default {
SearchForm,
},
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
mailbox,
pagination,
delayedRefresh: false,
}
};
},
watch: {
$route(to, from) {
this.doSearch()
}
this.doSearch();
},
},
mounted() {
mailbox.searching = this.getSearch()
this.doSearch()
mailbox.searching = this.getSearch();
this.doSearch();
// subscribe to events
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
this.eventBus.on("update", this.handleWSUpdate);
this.eventBus.on("delete", this.handleWSDelete);
this.eventBus.on("truncate", this.handleWSTruncate);
},
unmounted() {
// unsubscribe from events
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
this.eventBus.off("update", this.handleWSUpdate);
this.eventBus.off("delete", this.handleWSDelete);
this.eventBus.off("truncate", this.handleWSTruncate);
},
methods: {
doSearch() {
const s = this.getSearch()
const s = this.getSearch();
if (!s) {
mailbox.searching = false
this.$router.push('/')
return
mailbox.searching = false;
this.$router.push("/");
return;
}
mailbox.searching = s
mailbox.searching = s;
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
this.apiURI = this.resolve(`/api/v1/search`) + "?query=" + encodeURIComponent(s);
if (mailbox.timeZone !== "" && (s.indexOf("after:") !== -1 || s.indexOf("before:") !== -1)) {
this.apiURI += "&tz=" + encodeURIComponent(mailbox.timeZone);
}
this.loadMessages()
this.loadMessages();
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
if (this.mailbox.messages[x].ID === data.ID) {
// update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
return
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data };
return;
}
}
},
@@ -92,52 +92,57 @@ export default {
handleWSDelete(data) {
let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
if (this.mailbox.messages[x].ID === data.ID) {
// remove message from the list
this.mailbox.messages.splice(x, 1)
removed++
continue
this.mailbox.messages.splice(x, 1);
removed++;
continue;
}
}
if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued, don't refresh
return
return;
}
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true
this.delayedRefresh = true;
window.setTimeout(() => {
this.delayedRefresh = false
this.loadMessages()
}, 500)
this.delayedRefresh = false;
this.loadMessages();
}, 500);
},
// handler for websocket message truncation
handleWSTruncate() {
// all messages deleted, go back to inbox
this.$router.push('/')
this.$router.push("/");
},
}
}
},
};
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
<div class="col-xl-2 col-md-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" />
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink>
</div>
<div class="col col-md-4k col-lg-5 col-xl-6">
<SearchForm @loadMessages="loadMessages" />
<SearchForm @load-messages="loadMessages" />
</div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
<div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvas" aria-controls="offcanvas">
<button
class="btn btn-outline-light me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvas"
aria-controls="offcanvas"
>
<i class="bi bi-list"></i>
</button>
</div>
@@ -145,41 +150,51 @@ export default {
</div>
</div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
aria-labelledby="offcanvasLabel">
<div
id="offcanvas"
class="offcanvas-md offcanvas-start d-md-none"
data-bs-scroll="true"
tabindex="-1"
aria-labelledby="offcanvasLabel"
>
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
aria-label="Close"></button>
<h5 id="offcanvasLabel" class="offcanvas-title">Mailpit</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target="#offcanvas"
aria-label="Close"
></button>
</div>
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<NavSearch @loadMessages="loadMessages" />
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @load-messages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
<About />
</div>
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="row flex-fill" style="min-height: 0">
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<NavSearch @loadMessages="loadMessages" />
<div class="flex-grow-1 overflow-y-auto me-n3 pe-3">
<NavSearch @load-messages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
<About />
</div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<div id="message-page" class="mh-100" style="overflow-y: auto">
<ListMessages :loading-messages="loading" />
</div>
</div>
</div>
<NavSearch @loadMessages="loadMessages" modals />
<AboutMailpit modals />
<NavSearch modals @load-messages="loadMessages" />
<About modals />
<AjaxLoader :loading="loading" />
</template>

View File

@@ -1102,8 +1102,8 @@
},
"DatabaseSize": {
"description": "Database size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"LatestVersion": {
"description": "Latest Mailpit version",
@@ -1111,8 +1111,8 @@
},
"Messages": {
"description": "Total number of messages in the database",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"RuntimeStats": {
"description": "Runtime statistics",
@@ -1125,33 +1125,33 @@
},
"MessagesDeleted": {
"description": "Database runtime messages deleted",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPAccepted": {
"description": "Accepted runtime SMTP messages",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPAcceptedSize": {
"description": "Total runtime accepted messages size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPIgnored": {
"description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"SMTPRejected": {
"description": "Rejected runtime SMTP messages",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Uptime": {
"description": "Mailpit server uptime in seconds",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
}
}
},
@@ -1165,8 +1165,8 @@
},
"Unread": {
"description": "Total number of messages in the database",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Version": {
"description": "Current Mailpit version",
@@ -1197,8 +1197,8 @@
},
"Size": {
"description": "Size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
@@ -1436,6 +1436,32 @@
"x-go-name": "Response",
"x-go-package": "github.com/axllent/mailpit/internal/linkcheck"
},
"ListUnsubscribe": {
"description": "ListUnsubscribe contains a summary of List-Unsubscribe \u0026 List-Unsubscribe-Post headers\nincluding validation of the link structure",
"type": "object",
"properties": {
"Errors": {
"description": "Validation errors (if any)",
"type": "string"
},
"Header": {
"description": "List-Unsubscribe header value",
"type": "string"
},
"HeaderPost": {
"description": "List-Unsubscribe-Post value (if set)",
"type": "string"
},
"Links": {
"description": "Detected links, maximum one email and one HTTP(S) link",
"type": "array",
"items": {
"type": "string"
}
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
},
"Message": {
"description": "Message data excluding physical attachments",
"type": "object",
@@ -1462,7 +1488,7 @@
}
},
"Date": {
"description": "Message date if set, else date received",
"description": "Message RFC3339Nano date \u0026 time (if set), else date \u0026 time received\n([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)",
"type": "string",
"format": "date-time"
},
@@ -1484,6 +1510,9 @@
"$ref": "#/definitions/Attachment"
}
},
"ListUnsubscribe": {
"$ref": "#/definitions/ListUnsubscribe"
},
"MessageID": {
"description": "Message ID",
"type": "string"
@@ -1501,8 +1530,8 @@
},
"Size": {
"description": "Message size in bytes",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Subject": {
"description": "Message subject",
@@ -1525,6 +1554,10 @@
"items": {
"$ref": "#/definitions/Address"
}
},
"Username": {
"description": "Username used for authentication (if provided) with the SMTP or Send API",
"type": "string"
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
@@ -1565,7 +1598,7 @@
}
},
"Created": {
"description": "Created time",
"description": "Received RFC3339Nano date \u0026 time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)",
"type": "string",
"format": "date-time"
},
@@ -1593,8 +1626,8 @@
},
"Size": {
"description": "Message size in bytes (total)",
"type": "number",
"format": "double"
"type": "integer",
"format": "uint64"
},
"Snippet": {
"description": "Message snippet includes up to 250 characters",
@@ -1617,6 +1650,10 @@
"items": {
"$ref": "#/definitions/Address"
}
},
"Username": {
"description": "Username used for authentication (if provided) with the SMTP or Send API",
"type": "string"
}
},
"x-go-package": "github.com/axllent/mailpit/internal/storage"
@@ -1635,14 +1672,14 @@
},
"messages_count": {
"description": "Total number of messages matching current query",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "MessagesCount"
},
"messages_unread": {
"description": "Total number of unread messages matching current query",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "MessagesUnreadCount"
},
"start": {
@@ -1661,14 +1698,14 @@
},
"total": {
"description": "Total number of messages in mailbox",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "Total"
},
"unread": {
"description": "Total number of unread messages in mailbox",
"type": "number",
"format": "double",
"type": "integer",
"format": "uint64",
"x-go-name": "Unread"
}
},
@@ -1959,6 +1996,10 @@
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"HideDeleteAllButton": {
"description": "Whether the delete button should be hidden",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"
@@ -1983,6 +2024,10 @@
"description": "Overrides the \"From\" address for all relayed messages",
"type": "string"
},
"PreserveMessageIDs": {
"description": "Preserve the original Message-IDs when relaying messages",
"type": "boolean"
},
"ReturnPath": {
"description": "Enforced Return-Path (if set) for relay bounces",
"type": "string"