Compare commits

...

137 Commits

Author SHA1 Message Date
Ralph Slooten
c5ea550631 Merge branch 'release/v1.9.8' 2023-10-16 21:18:11 +13:00
Ralph Slooten
b4f5aa3640 Release v1.9.8 2023-10-16 21:18:10 +13:00
Ralph Slooten
82d54d354c Libs: Update node modules 2023-10-16 21:17:35 +13:00
Ralph Slooten
7185649bbd Libs: Update Go modules 2023-10-16 21:15:56 +13:00
Ralph Slooten
506400b764 Merge branch 'feature/swagger' into develop 2023-10-16 21:13:17 +13:00
Cyril Jouve
0e01b9ff73 Chore: Replace satori/go.uuid with github.com/google/uuid (#190)
Fixes #189
2023-10-16 19:22:47 +13:00
Ralph Slooten
4c3e073b0c Change swagger BinaryResponse to os.File 2023-10-16 17:34:10 +13:00
Ralph Slooten
e72dd8d9b6 Replace unprintable characters with space in html2text 2023-10-15 22:02:57 +13:00
Ralph Slooten
e564637203 Tests: Add test to validate swagger.json 2023-10-15 20:55:43 +13:00
Ralph Slooten
cded4d25fc Swagger: Update swagger documentation
See #188
2023-10-15 19:24:06 +13:00
Ralph Slooten
eeac32d09b Merge branch 'feature/html2text' into develop 2023-10-14 22:29:26 +13:00
Ralph Slooten
e9d44c55a1 Tests: Add html2text tests 2023-10-14 22:28:52 +13:00
Ralph Slooten
a9fe0d8e58 Chore: Replace html2text modules with simplified internal function
The module microcosm-cc/bluemonday now requires Go v1.21 and is quite frankly an overkill as Mailpit only needs to convert HTML to a single line (no formatting).
2023-10-14 22:28:14 +13:00
Ralph Slooten
93da18778c Merge tag 'v1.9.7' into develop
Release v1.9.7
2023-10-13 23:22:09 +13:00
Ralph Slooten
9b67792669 Merge branch 'release/v1.9.7' 2023-10-13 23:22:06 +13:00
Ralph Slooten
8739428136 Release v1.9.7 2023-10-13 23:22:05 +13:00
Ralph Slooten
97ec3e839b Libs: Update node modules 2023-10-13 23:20:25 +13:00
Ralph Slooten
56d61ae24b Fix: Enable delete button when new messages arrive
See #185
2023-10-13 23:09:49 +13:00
Ralph Slooten
d43560d45b Libs: Downgrade microcosm-cc/bluemonday, revert to Go 1.20 2023-10-13 23:00:05 +13:00
Andrew Minion
a0e69a202a Add docs for brew services 2023-10-13 22:42:37 +13:00
Ralph Slooten
fc95241521 Libs: Update Go modules & minimum Go version (1.21) 2023-10-13 22:35:06 +13:00
Ralph Slooten
831157a52e Shorten release job name to just "Build" 2023-10-06 17:18:38 +13:00
Ralph Slooten
18c3847deb Merge tag 'v1.9.6' into develop
Release v1.9.6
2023-10-06 17:10:33 +13:00
Ralph Slooten
21134c5bbc Merge branch 'release/v1.9.6' 2023-10-06 17:10:29 +13:00
Ralph Slooten
b34877b3ff Release v1.9.6 2023-10-06 17:10:29 +13:00
Ralph Slooten
47d6e319e3 Libs: Update node modules 2023-10-06 17:06:49 +13:00
Ralph Slooten
a64e964c39 Libs: Update Go modules 2023-10-06 17:05:35 +13:00
Ralph Slooten
e5703d0805 UI: Display message previews on separate line (#175) 2023-10-06 17:04:03 +13:00
Ralph Slooten
c004c1065e Merge tag 'v1.9.5' into develop
Release v1.9.5
2023-10-05 17:39:21 +13:00
Ralph Slooten
af93444374 Merge branch 'release/v1.9.5' 2023-10-05 17:39:19 +13:00
Ralph Slooten
840bc94190 Release v1.9.5 2023-10-05 17:39:19 +13:00
Ralph Slooten
4e2d4d6365 Fix: HTML message preview background color when switching themes in Chrome
Fixes  #182
2023-10-05 17:38:26 +13:00
Ralph Slooten
7446f52205 Fix: Correctly detect tags in search (UI) 2023-10-05 17:23:22 +13:00
Ralph Slooten
d4218df1cf Merge branch 'feature/snippets' into develop 2023-10-05 17:04:25 +13:00
Ralph Slooten
2b18b1bee1 Feature: Add reindex subcommand to reindex all messages 2023-10-05 17:04:05 +13:00
Ralph Slooten
a3f83ea5ce Tests: Add message summary tests 2023-10-05 17:02:35 +13:00
Ralph Slooten
52405915fa Tests: Add snippet tests 2023-10-05 17:02:12 +13:00
Ralph Slooten
636918dd0e Feature: Display email previews (#175) 2023-10-05 17:01:13 +13:00
dependabot[bot]
3fb926f015 Bump docker/setup-qemu-action from 2 to 3 (#177)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
2023-10-01 21:27:39 +13:00
dependabot[bot]
0af6850d34 Bump actions/checkout from 3 to 4 (#178)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
2023-10-01 21:24:24 +13:00
dependabot[bot]
66660b9074 Bump docker/setup-buildx-action from 2 to 3 (#179)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
2023-10-01 21:23:52 +13:00
dependabot[bot]
3b43a803af Bump docker/build-push-action from 4 to 5 (#180)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
2023-10-01 21:22:37 +13:00
dependabot[bot]
ec3dd0c196 Bump docker/login-action from 2 to 3 (#181)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
2023-10-01 21:22:09 +13:00
Ralph Slooten
38240ae96d Merge tag 'v1.9.4' into develop
Release v1.9.4
2023-09-29 16:52:04 +13:00
Ralph Slooten
d0087423db Merge branch 'release/v1.9.4' 2023-09-29 16:52:03 +13:00
Ralph Slooten
1ac8e3a79f Release v1.9.4 2023-09-29 16:52:02 +13:00
Ralph Slooten
67dedd8acc Libs: Update node modules 2023-09-29 16:49:42 +13:00
Ralph Slooten
4f6caca352 Libs: Update Go modules 2023-09-29 16:46:49 +13:00
Ralph Slooten
b6fdcd4ec5 Merge branch 'feature/basic-auth-via-env' into develop 2023-09-29 16:44:52 +13:00
Ralph Slooten
044525fcca Chore: Remove some flags deprecated 08/2022 2023-09-29 16:44:03 +13:00
Ralph Slooten
0ab4210640 Feature: Set auth credentials directly from environment variables
Credentials for the UI and SMTP can now be exported via the `MP_UI_AUTH` and `MP_SMTP_AUTH` environment variables. See #173
2023-09-29 16:40:23 +13:00
Ralph Slooten
e902806ea2 UI: Add option to delete a message after release
See #169
2023-09-28 16:05:44 +13:00
Ralph Slooten
f2b6ba0d69 Merge tag 'v1.9.3' into develop
Release v1.9.3
2023-09-27 17:32:17 +13:00
Ralph Slooten
55bdd45247 Merge branch 'release/v1.9.3' 2023-09-27 17:32:15 +13:00
Ralph Slooten
0b3a5fc5d8 Release v1.9.3 2023-09-27 17:32:15 +13:00
Ralph Slooten
3e90391991 Merge branch 'feature/ui-tests' into develop 2023-09-27 17:29:20 +13:00
Ralph Slooten
ae15cac727 Testing: Add endpoints for integration tests
See #166
2023-09-27 17:29:03 +13:00
Ralph Slooten
1020f76bf8 UI: Do not show excluded search tags as "current" in nav 2023-09-26 19:04:04 +13:00
Ralph Slooten
42a1fe1510 UI: Display "Loading messages" instead of "No results" while loading results 2023-09-26 16:51:30 +13:00
Ralph Slooten
628b7e7881 Code cleanup 2023-09-25 22:14:39 +13:00
Ralph Slooten
fe5de77253 Tests: Add more API tests 2023-09-25 22:14:19 +13:00
Ralph Slooten
36eef88885 Merge branch 'feature/structure' into develop 2023-09-25 19:39:08 +13:00
Ralph Slooten
737cff5a96 Chore: Update internal/storage import paths 2023-09-25 19:29:32 +13:00
Ralph Slooten
009a7deaa1 Chore: Move storage package to internal/storage 2023-09-25 19:29:31 +13:00
Ralph Slooten
b6d5a8c182 Chore: Update internal import paths 2023-09-25 19:29:30 +13:00
Ralph Slooten
10224e7c8b Chore: Move utils/* packages to internal/* 2023-09-25 19:29:02 +13:00
Ralph Slooten
d2086922e5 UI: Only queue broadcast events if clients are connected 2023-09-25 16:53:25 +13:00
Ralph Slooten
3c744edd20 Tests: Add tests for ArgsParser & CleanTag 2023-09-25 16:07:11 +13:00
Ralph Slooten
7ed522e596 Merge tag 'v1.9.2' into develop
Release v1.9.2
2023-09-24 19:16:29 +13:00
Ralph Slooten
26c6f9d965 Merge branch 'release/v1.9.2' 2023-09-24 19:16:23 +13:00
Ralph Slooten
76a261bf06 Release v1.9.2 2023-09-24 19:16:22 +13:00
Ralph Slooten
86a3bea300 Libs: Update node modules 2023-09-24 19:14:46 +13:00
Ralph Slooten
5fa6b20a53 Update tag test message 2023-09-24 19:10:41 +13:00
Ralph Slooten
3ad62769a6 Tests: Add message tag tests 2023-09-24 19:08:47 +13:00
Ralph Slooten
a63952aee6 Tests: Add search delete tests 2023-09-24 17:29:27 +13:00
Ralph Slooten
de95910539 Change recipients <name>2@example.com 2023-09-24 17:27:02 +13:00
Ralph Slooten
60a41ce3ca Fix: Delete all messages matching search when more than 1000 results 2023-09-24 13:07:16 +13:00
Ralph Slooten
898b36ce0b UI: Reset pagination when returning to inbox from search 2023-09-24 12:24:52 +13:00
Ralph Slooten
b4a4d44492 Merge tag 'v1.9.1' into develop
Release v1.9.1
2023-09-23 22:58:14 +12:00
Ralph Slooten
64e4e4240a Merge branch 'release/v1.9.1' 2023-09-23 22:58:11 +12:00
Ralph Slooten
0477c6573f Release v1.9.1 2023-09-23 22:58:10 +12:00
Ralph Slooten
28ac6d2099 UI: Set 404 page when loading a non-existent message 2023-09-23 15:49:43 +12:00
Ralph Slooten
43a1dbe3f0 Chore: Update caniemail data 2023-09-23 14:56:57 +12:00
Ralph Slooten
aa3f860540 Libs: Update Go modules 2023-09-23 11:51:29 +12:00
Ralph Slooten
f54a2187ac UI: Link email addresses in message summary to search 2023-09-23 11:48:06 +12:00
Ralph Slooten
063eab2c6a UI: Better support for mobile screen sizes 2023-09-23 09:31:02 +12:00
Ralph Slooten
b282e6663b Remove redundant Read status from message (always true) 2023-09-22 21:31:35 +12:00
Ralph Slooten
df777c6e90 Merge tag 'v1.9.0' into develop
Release v1.9.0
2023-09-22 16:40:51 +12:00
Ralph Slooten
8c4b1ac445 Merge branch 'release/v1.9.0' 2023-09-22 16:40:49 +12:00
Ralph Slooten
309c56566c Release v1.9.0 2023-09-22 16:40:48 +12:00
Ralph Slooten
12d47a0f82 Merge branch 'feature/routing' into develop 2023-09-22 16:34:59 +12:00
Ralph Slooten
27d601294a Libs: Update minimum Go version to 1.20 2023-09-22 16:34:47 +12:00
Ralph Slooten
98343714be Tests: Bump Go version to 1.21 2023-09-22 15:32:51 +12:00
Ralph Slooten
930901c4ec Libs: Update Go modules 2023-09-22 15:27:58 +12:00
Ralph Slooten
446cae145f Update regex in string cleaner 2023-09-22 15:27:02 +12:00
Ralph Slooten
6a4e5fb03c UI: Rewrite web UI, add URL routing and components
See #156
2023-09-22 15:06:03 +12:00
Ralph Slooten
8f0549c596 Libs: Update node modules 2023-09-22 15:01:33 +12:00
Ralph Slooten
4a762c502e Add Swagger note 2023-09-22 07:11:13 +12:00
Ralph Slooten
9af04f83a3 API: Remove redundant Read status from message (always true) 2023-09-22 07:07:40 +12:00
Ralph Slooten
8e0c174bf3 Code cleanup 2023-09-22 07:02:15 +12:00
Ralph Slooten
b193851269 API: Delete by search filter
See #164
2023-09-22 07:00:02 +12:00
Ralph Slooten
95e346f8af Improved search parser 2023-09-22 06:55:51 +12:00
Ralph Slooten
582f1f88b2 API: Add endpoint to return all tags in use 2023-09-22 06:55:20 +12:00
Ralph Slooten
0d084cfa1d Feature: Improved search parser 2023-09-22 06:46:23 +12:00
Ralph Slooten
aa0af5de32 Update api search docs 2023-09-15 19:08:53 +12:00
Ralph Slooten
ee49149df9 Feature: New search filter [!]is:tagged
See #164
2023-09-14 22:30:20 +12:00
Ralph Slooten
e18c45d0b3 Fix: Correctly escape certain characters in search (eg: ') 2023-09-14 22:30:10 +12:00
Ralph Slooten
87a68f6a53 Merge tag 'v1.8.4' into develop
Release v1.8.4
2023-09-06 17:29:33 +12:00
Ralph Slooten
6d35b7bc82 Merge branch 'release/v1.8.4' 2023-09-06 17:29:30 +12:00
Ralph Slooten
6cf7cba6b7 Release v1.8.4 2023-09-06 17:29:30 +12:00
Ralph Slooten
9788a01617 Fix: Correctly decode proxy links containing HTML entities (screenshots) 2023-09-06 17:28:48 +12:00
Ralph Slooten
f4923c34ae Update README 2023-09-06 16:37:21 +12:00
Ralph Slooten
b2ce855774 Merge tag 'v1.8.3' into develop
Release v1.8.3
2023-09-06 16:21:09 +12:00
Ralph Slooten
d489675c42 Merge branch 'release/v1.8.3' 2023-09-06 16:21:07 +12:00
Ralph Slooten
2ebaaa0fb2 Release v1.8.3 2023-09-06 16:21:07 +12:00
Ralph Slooten
80eba20679 Update README 2023-09-06 16:20:29 +12:00
Ralph Slooten
1757a0086e Merge branch 'feature/screenshot' into develop 2023-09-06 16:15:37 +12:00
Ralph Slooten
e265d7018e Fix docblock comment 2023-09-06 16:14:54 +12:00
Ralph Slooten
a37da776d7 Feature: HTML screenshots
Resolves #157
2023-09-06 16:14:35 +12:00
Ralph Slooten
5baa598453 Libs: Update node modules 2023-09-02 22:34:22 +12:00
dependabot[bot]
9d4bbe82e3 Bump wangyoucao577/go-release-action from 1.39 to 1.40 (#158)
Bumps [wangyoucao577/go-release-action](https://github.com/wangyoucao577/go-release-action) from 1.39 to 1.40.
- [Release notes](https://github.com/wangyoucao577/go-release-action/releases)
- [Commits](https://github.com/wangyoucao577/go-release-action/compare/v1.39...v1.40)

---
updated-dependencies:
- dependency-name: wangyoucao577/go-release-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-02 22:14:44 +12:00
Ralph Slooten
69226e91b2 UI: Group message tabs on mobile 2023-08-17 17:04:15 +12:00
Ralph Slooten
8646efc979 Merge tag 'v1.8.2' into develop
Release v1.8.2
2023-08-16 17:26:41 +12:00
Ralph Slooten
7c42540427 Merge branch 'release/v1.8.2' 2023-08-16 17:26:27 +12:00
Ralph Slooten
c695cd23f6 Release v1.8.2 2023-08-16 17:26:27 +12:00
Ralph Slooten
bc53a34029 Build: Update wangyoucao577/go-release-action@v1.39 2023-08-16 17:26:01 +12:00
Ralph Slooten
270d5f534f Merge tag 'v1.8.2' into develop
Release v1.8.2
2023-08-16 17:17:24 +12:00
Ralph Slooten
6a34c449a2 Merge branch 'release/v1.8.2' 2023-08-16 17:17:21 +12:00
Ralph Slooten
1723497c5c Release v1.8.2 2023-08-16 17:17:20 +12:00
Ralph Slooten
57e856f941 Libs: Update Go libs 2023-08-16 17:06:04 +12:00
Ralph Slooten
72d780fe66 Merge branch 'feature/check-links' into develop 2023-08-16 17:03:02 +12:00
Ralph Slooten
4768b7b08c Update README 2023-08-16 16:59:37 +12:00
Ralph Slooten
d01fb4044e Feature: Link check to test message links
@see #151
2023-08-16 16:59:31 +12:00
Ralph Slooten
8dbc661cb7 Use message ID as key for Message component 2023-08-15 21:32:12 +12:00
Ralph Slooten
bc4b028c39 UI: Set hostname in page meta title to identify Mailpit instance
@see #154
2023-08-15 21:31:18 +12:00
Ralph Slooten
7875160aa7 Feature: Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
Due to a bug in some common sendmail implementations and PHP >=8.0, message headers sometimes contain `\r\r\n` which is not RFC compliant.

Mailpit will now fix these non-compliant headers. This can be disabled via `--smtp-strict-rfc-headers`

See #87 / #153
2023-08-15 17:13:25 +12:00
Ralph Slooten
f0c77ac962 Merge tag 'v1.8.1' into develop
Release v1.8.1
2023-08-06 17:07:38 +12:00
108 changed files with 7094 additions and 3421 deletions

View File

@@ -8,16 +8,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
@@ -30,7 +30,7 @@ jobs:
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64

View File

@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -5,7 +5,7 @@ on:
name: Build & release
jobs:
releases-matrix:
name: Release Go Binary
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
@@ -21,7 +21,7 @@ jobs:
- goarch: arm
goos: windows
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
# build the assets
- uses: actions/setup-node@v3
@@ -33,7 +33,7 @@ jobs:
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.38
- uses: wangyoucao577/go-release-action@v1.40
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}

View File

@@ -1,22 +1,23 @@
name: Tests
on:
pull_request:
branches: [ develop ]
branches: [ develop, 'feature/**' ]
push:
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
matrix:
go-version: [1.18.x]
go-version: [1.21.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
- uses: actions/cache@v3
- uses: actions/checkout@v4
- name: Run Go tests
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
@@ -24,13 +25,20 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./storage ./server -v
- run: go test ./storage -bench=.
- run: go test ./internal/storage ./server ./internal/tools ./internal/tools/html2text -v
- run: go test ./internal/storage ./internal/tools/html2text -bench=.
# build the assets
- uses: actions/setup-node@v3
- name: Build web UI
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- run: npm install
- run: npm run package
# validate the swagger file
- name: Validate OpenAPI definition
uses: char0n/swagger-editor-validate@v1
with:
definition-file: server/ui/api/v1/swagger.json

View File

@@ -2,6 +2,187 @@
Notable changes to Mailpit will be documented in this file.
## [v1.9.8]
### Chore
- Replace satori/go.uuid with github.com/google/uuid ([#190](https://github.com/axllent/mailpit/issues/190))
- Replace html2text modules with simplified internal function
### Libs
- Update node modules
- Update Go modules
### Swagger
- Update swagger documentation
### Tests
- Add test to validate swagger.json
- Add html2text tests
## [v1.9.7]
### Fix
- Enable delete button when new messages arrive
### Libs
- Update node modules
- Downgrade microcosm-cc/bluemonday, revert to Go 1.20
- Update Go modules & minimum Go version (1.21)
## [v1.9.6]
### Libs
- Update node modules
- Update Go modules
### UI
- Display message previews on separate line ([#175](https://github.com/axllent/mailpit/issues/175))
## [v1.9.5]
### Feature
- Add `reindex` subcommand to reindex all messages
- Display email previews ([#175](https://github.com/axllent/mailpit/issues/175))
### Fix
- HTML message preview background color when switching themes in Chrome
- Correctly detect tags in search (UI)
### Tests
- Add message summary tests
- Add snippet tests
## [v1.9.4]
### Chore
- Remove some flags deprecated 08/2022
### Feature
- Set auth credentials directly from environment variables
### Libs
- Update node modules
- Update Go modules
### UI
- Add option to delete a message after release
## [v1.9.3]
### Chore
- Update internal/storage import paths
- Move storage package to internal/storage
- Update internal import paths
- Move utils/* packages to internal/*
### Testing
- Add endpoints for integration tests
### Tests
- Add more API tests
- Add tests for ArgsParser & CleanTag
### UI
- Do not show excluded search tags as "current" in nav
- Display "Loading messages" instead of "No results" while loading results
- Only queue broadcast events if clients are connected
## [v1.9.2]
### Fix
- Delete all messages matching search when more than 1000 results
### Libs
- Update node modules
### Tests
- Add message tag tests
- Add search delete tests
### UI
- Reset pagination when returning to inbox from search
## [v1.9.1]
### Chore
- Update caniemail data
### Libs
- Update Go modules
### UI
- Set 404 page when loading a non-existent message
- Link email addresses in message summary to search
- Better support for mobile screen sizes
## [v1.9.0]
### API
- Remove redundant `Read` status from message (always true)
- Delete by search filter
- Add endpoint to return all tags in use
### Feature
- Improved search parser
- New search filter `[!]is:tagged`
### Fix
- Correctly escape certain characters in search (eg: `'`)
### Libs
- Update minimum Go version to 1.20
- Update Go modules
- Update node modules
### Tests
- Bump Go version to 1.21
### UI
- Rewrite web UI, add URL routing and components
## [v1.8.4]
### Fix
- Correctly decode proxy links containing HTML entities (screenshots)
## [v1.8.3]
### Feature
- HTML screenshots
### Libs
- Update node modules
### UI
- Group message tabs on mobile
## [v1.8.2]
### Build
- Update wangyoucao577/go-release-action[@v1](https://github.com/v1).39
### Feature
- Link check to test message links
- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
### Libs
- Update Go libs
### UI
- Set hostname in page meta title to identify Mailpit instance
## [v1.8.1]
### Docs

View File

@@ -6,11 +6,11 @@
![CodeQL](https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/axllent/mailpit)](https://goreportcard.com/report/github.com/axllent/mailpit)
Mailpit is a multi-platform email testing tool & API for developers.
Mailpit is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
It acts as both an SMTP server, and provides a web interface to view all captured emails. It also contains an API for automated integration testing.
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and contains an API for automated integration testing.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but modern and much, much faster.
Mailpit was originally **inspired** by MailHog which is now [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development for a few years now.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/docs/screenshot.png)
@@ -21,8 +21,10 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but modern and much, muc
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
- HTML check to test & score mail client compatibility with HTML emails
- Light & dark web UI theme with auto-detect
- Link check to test message links (HTML & text) & linked images
- Screenshots of HTML messages via web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTML-screenshots))
- Mobile and tablet HTML preview toggle in desktop mode
- Light & dark web UI theme with auto-detect
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging))
- Real-time web UI updates using web sockets for new mail
@@ -49,6 +51,8 @@ Mailpit runs as a single binary and can be installed in different ways:
Install Mailpit with `brew install mailpit`.
To run automatically run mailpit in the background, run `brew services start mailpit`.
### Install via bash script (Linux & Mac)
@@ -78,7 +82,7 @@ To build Mailpit from source see [building from source](https://github.com/axlle
Run `mailpit -h` to see options. More information can be seen in [the docs](https://github.com/axllent/mailpit/wiki/Runtime-options).
If installed using homebrew, you may run `brew services start mailpit` to run always run mailpit automatically.
### Testing Mailpit
@@ -88,12 +92,3 @@ Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Test
### Configuring sendmail
Mailpit's SMTP server (by default on port 1025), so you will likely need to configure your sending application to deliver mail via that port. A common MTA (Mail Transfer Agent) that delivers system emails to a SMTP server is `sendmail`, used by many applications including PHP. Mailpit can also act as substitute for sendmail. For instructions of how to set this up, please refer to the [sendmail documentation](https://github.com/axllent/mailpit/wiki/Configuring-sendmail).
## Why rewrite MailHog?
I had been using MailHog for a few years to intercept and test emails, but experienced a number of severe performance issues. Many of the frontend and Go libraries are very out of date, and the project [is no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258).
Initially I tried to upgrade a fork of MailHog (the UI, the HTTP server and the API), but discovered that it is (with all due respect to its authors) far too complex. I found it over-engineered (split over 9 separate projects), and performs very poorly when dealing with large amounts of emails or emails with attachments (a single email with a 3MB attachment can take over a minute to ingest). Finally the API transmits a lot of duplicate & irrelevant data on every browser request, all without any HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

40
cmd/reindex.go Normal file
View File

@@ -0,0 +1,40 @@
/*
Copyright © 2022-Now() Ralph Slooten
This file is part of a CLI application.
*/
package cmd
import (
"os"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/spf13/cobra"
)
// reindexCmd represents the reindex command
var reindexCmd = &cobra.Command{
Use: "reindex <database>",
Short: "Reindex the database",
Long: `This will reindex all messages in the entire database.
If you have several thousand messages in your mailbox, then it is advised to shut down
Mailpit while you reindex as this process will likely result in database locking issues.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
config.DataFile = args[0]
config.MaxMessages = 0
if err := storage.InitDB(); err != nil {
logger.Log().Error(err)
os.Exit(1)
}
storage.ReindexAll()
},
}
func init() {
rootCmd.AddCommand(reindexCmd)
}

View File

@@ -8,10 +8,11 @@ import (
"strings"
"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"
"github.com/axllent/mailpit/server/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/spf13/cobra"
)
@@ -91,7 +92,7 @@ func init() {
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
@@ -100,6 +101,7 @@ func init() {
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
@@ -108,22 +110,6 @@ func init() {
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
// deprecated flags 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ssl-cert", config.UITLSCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ssl-key", config.UITLSKey, "SSL key - requires ssl-cert")
rootCmd.Flags().Lookup("auth-file").Hidden = true
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-tls-key"
// deprecated flags 2022/08/30
rootCmd.Flags().StringVar(&config.DataFile, "data", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().Lookup("data").Hidden = true
rootCmd.Flags().Lookup("data").Deprecated = "use --db-file"
// deprecated flags 2023/03/12
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
@@ -141,10 +127,8 @@ func init() {
// Load settings from environment
func initConfigFromEnv() {
// defaults from envars if provided
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.DataFile = os.Getenv("MP_DATA_FILE")
}
// inherit from environment if provided
config.DataFile = os.Getenv("MP_DATA_FILE")
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
@@ -159,37 +143,28 @@ func initConfigFromEnv() {
}
// UI
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
}
if len(os.Getenv("MP_UI_TLS_CERT")) > 0 {
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
}
if len(os.Getenv("MP_UI_TLS_KEY")) > 0 {
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
}
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
auth.SetUIAuth(os.Getenv("MP_UI_AUTH"))
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
// SMTP
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
}
if len(os.Getenv("MP_SMTP_TLS_CERT")) > 0 {
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
}
if len(os.Getenv("MP_SMTP_TLS_KEY")) > 0 {
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
}
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH"))
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
config.SMTPStrictRFCHeaders = true
}
// Relay server config
if len(os.Getenv("MP_SMTP_RELAY_CONFIG")) > 0 {
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
}
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAllIncoming = true
}
@@ -223,39 +198,22 @@ func initConfigFromEnv() {
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
fmt.Println("ENV MP_AUTH_FILE has been deprecated, use MP_UI_AUTH_FILE")
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_SSL_CERT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_TLS_KEY")
}
// deprecated 2022/08/28
if len(os.Getenv("MP_DATA_DIR")) > 0 {
fmt.Println("ENV MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
config.DataFile = os.Getenv("MP_DATA_DIR")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")

View File

@@ -6,7 +6,7 @@ import (
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/updater"
"github.com/axllent/mailpit/internal/updater"
"github.com/spf13/cobra"
)

View File

@@ -10,10 +10,9 @@ import (
"regexp"
"strings"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
@@ -39,12 +38,9 @@ var (
// UITLSKey file
UITLSKey string
// UIAuthFile for basic authentication
// UIAuthFile for UI & API authentication
UIAuthFile string
// UIAuth used for authentication
UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
Webroot = "/"
@@ -57,9 +53,6 @@ var (
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuthConfig used for authentication auto-generated from SMTPAuthFile
SMTPAuthConfig *htpasswd.File
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
@@ -90,6 +83,10 @@ var (
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
SMTPRelayConfig smtpRelayConfigStruct
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
SMTPStrictRFCHeaders bool
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
@@ -158,12 +155,13 @@ func VerifyConfig() error {
if !isFile(UIAuthFile) {
return fmt.Errorf("HTTP password file not found: %s", UIAuthFile)
}
a, err := htpasswd.New(UIAuthFile, htpasswd.DefaultSystems, nil)
b, err := os.ReadFile(UIAuthFile)
if err != nil {
return err
}
UIAuth = a
if err := auth.SetUIAuth(string(b)); err != nil {
return err
}
}
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
@@ -199,18 +197,21 @@ func VerifyConfig() error {
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
if SMTPAuthAcceptAny {
return errors.New("SMTP authentication can either use --smtp-auth-file or --smtp-auth-accept-any")
}
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
b, err := os.ReadFile(SMTPAuthFile)
if err != nil {
return err
}
SMTPAuthConfig = a
if err := auth.SetSMTPAuth(string(b)); err != nil {
return err
}
}
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
return errors.New("SMTP authentication cannot use both credentials and --smtp-auth-accept-any")
}
if SMTPTLSCert == "" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
@@ -224,13 +225,8 @@ func VerifyConfig() error {
SMTPTags = []AutoTag{}
p := shellwords.NewParser()
if SMTPCLITags != "" {
args, err := p.Parse(SMTPCLITags)
if err != nil {
return fmt.Errorf("Error parsing tags (%s)", err)
}
args := tools.ArgsParser(SMTPCLITags)
for _, a := range args {
t := strings.Split(a, "=")

View File

@@ -16,7 +16,6 @@ Returns a JSON summary of the message and attachments.
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"Read": true,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
@@ -58,7 +57,6 @@ Returns a JSON summary of the message and attachments.
```
### Notes
- `Read` - always true (message marked read on open)
- `From` - Name & Address, or null
- `To`, `CC`, `BCC`, `ReplyTo` - Array of Names & Address
- `Date` - Parsed email local date & time from headers

35
go.mod
View File

@@ -1,32 +1,30 @@
module github.com/axllent/mailpit
go 1.18
go 1.20
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/PuerkitoBio/goquery v1.8.1
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386
github.com/google/uuid v1.3.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v1.0.0
github.com/k3a/html2text v1.2.1
github.com/klauspost/compress v1.16.7
github.com/jhillyerd/enmime v1.0.1
github.com/klauspost/compress v1.17.1
github.com/leporo/sqlf v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.1
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.14.0
golang.org/x/text v0.12.0
golang.org/x/net v0.17.0
golang.org/x/text v0.13.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.25.0
modernc.org/sqlite v1.26.0
)
require (
@@ -37,7 +35,6 @@ require (
github.com/cznic/ql v1.2.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.3.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
@@ -53,19 +50,19 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/image v0.11.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/tools v0.11.1 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/tools v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.14 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

82
go.sum
View File

@@ -47,18 +47,16 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 h1:EcQR3gusLHN46TAD+G+EbaaqJArt5vHhNpXAa12PQf4=
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
@@ -69,16 +67,12 @@ 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.0.0 h1:8swYgO1fm68PllCKz5jiLzgD3axNUS388jr6BtRSsl8=
github.com/jhillyerd/enmime v1.0.0/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/jhillyerd/enmime v1.0.1 h1:y6RyqIgBOI2hIinOXIzmeB+ITRVls0zTJIm5GwgXnjE=
github.com/jhillyerd/enmime v1.0.1/go.mod h1:LMMbm6oTlzWHghPavqHtOrP/NosVv3l42CUrZjn03/Q=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.1 h1:NE3C767s2ak2bweCZo3+rdP4U/HoyVXLv/X9f2gPS5g=
github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
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=
@@ -93,8 +87,6 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
@@ -117,14 +109,8 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -138,8 +124,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
@@ -154,17 +140,16 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
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 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -175,12 +160,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -193,8 +178,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -205,15 +190,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
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.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc=
golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -227,22 +211,22 @@ lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ=
modernc.org/ccgo/v3 v3.16.14/go.mod h1:mPDSujUIaTNWQSG4eqKw+atqLOEbma6Ncsa94WbC9zo=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

69
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,69 @@
// Package auth handles the web UI and SMTP authentication
package auth
import (
"regexp"
"strings"
"github.com/tg123/go-htpasswd"
)
var (
// UICredentials passwords
UICredentials *htpasswd.File
// SMTPCredentials passwords
SMTPCredentials *htpasswd.File
)
// SetUIAuth will set Basic Auth credentials required for the UI & API
func SetUIAuth(s string) error {
var err error
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
UICredentials, 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
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
SMTPCredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
return nil
}
func credentialsFromString(s string) []string {
// split string by any whitespace character
re := regexp.MustCompile(`\s+`)
words := re.Split(s, -1)
credentials := []string{}
for _, w := range words {
if w != "" {
credentials = append(credentials, w)
}
}
return credentials
}

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2023-07-25 17:42:58 +0000",
"last_update_date":"2023-09-22 13:57:52 +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"},"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":[
{
@@ -318,7 +318,7 @@
"last_test_date":"2023-07-24",
"test_url":"https://www.caniemail.com/tests/css-background.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/04SuPXr8tEGhWRlJ2Us6dA8BzgREpyxHYEmSBeyNuWyWo/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y","2023-07":"a #6"},"ios":{"2018-09":"a #1","2018-10":"y","2019-02":"y"},"android":{"2018-09":"a #1","2018-10":"y","2019-02":"y"},"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":"n #5","2010":"n #5","2013":"n #5","2016":"n #5","2019":"n #5"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"a #3 #4","2021-10":"a #3"},"ios":{"2019-02":"a #3 #4","2021-10":"a #3"},"android":{"2019-02":"a #3 #4","2021-10":"a #3"}},"aol":{"desktop-webmail":{"2019-02":"a #3 #4","2021-10":"a #3"},"ios":{"2019-02":"a #3 #4","2021-10":"a #3"},"android":{"2019-02":"a #3 #4","2021-10":"a #3"}},"samsung-email":{"android":{"5.0.10.2":"a #2","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":"y"}},"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":"n"}},"free-fr":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-06":"a #3"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"a #3"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y","2023-07":"a #6","2023-08":"y"},"ios":{"2018-09":"a #1","2018-10":"y","2019-02":"y"},"android":{"2018-09":"a #1","2018-10":"y","2019-02":"y"},"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":"n #5","2010":"n #5","2013":"n #5","2016":"n #5","2019":"n #5"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"a #3 #4","2021-10":"a #3"},"ios":{"2019-02":"a #3 #4","2021-10":"a #3"},"android":{"2019-02":"a #3 #4","2021-10":"a #3"}},"aol":{"desktop-webmail":{"2019-02":"a #3 #4","2021-10":"a #3"},"ios":{"2019-02":"a #3 #4","2021-10":"a #3"},"android":{"2019-02":"a #3 #4","2021-10":"a #3"}},"samsung-email":{"android":{"5.0.10.2":"a #2","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":"y"}},"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":"n"}},"free-fr":{"desktop-webmail":{"2021-11":"n"}},"gmx":{"desktop-webmail":{"2022-06":"a #3"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"a #3"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Not supported with non Gmail accounts.","2":"Buggy. Requires at least one `<img>` element in the email to download all images.","3":"Partial. Does not support multiple values. The comma between two values is removed.","4":"Partial. Images URL must be between quotes.","5":"Background images can be used in VML. See [backgrounds.cm](https://backgrounds.cm/) and [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element).","6":"Partial and buggy. Removes the entire `style` attribute or `<style>` tag when a `url()` function with a valid image URL is present. See [Gmail rolling out changes that strip CSS with background images](https://freshinbox.com/blog/gmail-rolling-out-changes-that-strip-background-image-css/) and [Gmail and background images](https://parcel.io/blog/gmail-and-background-images)."}
},
@@ -398,7 +398,7 @@
"last_test_date":"2023-07-24",
"test_url":"https://www.caniemail.com/tests/css-background.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/04SuPXr8tEGhWRlJ2Us6dA8BzgREpyxHYEmSBeyNuWyWo/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y","2023-07":"a #6 #7"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"a #6"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #3","2010":"a #3","2013":"a #3","2016":"a #3","2019":"a #3"},"windows-mail":{"2019-02":"n","2021-10":"a #3"},"macos":{"2019-02":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"ios":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"android":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"}},"aol":{"desktop-webmail":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"ios":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"android":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"}},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #5"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"a #1 #5 #6"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"a #1 #5 #6"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y","2023-07":"a #6 #7","2023-08":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"a #6"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"a #3","2010":"a #3","2013":"a #3","2016":"a #3","2019":"a #3"},"windows-mail":{"2019-02":"n","2021-10":"a #3"},"macos":{"2019-02":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"ios":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"android":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"}},"aol":{"desktop-webmail":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"ios":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"},"android":{"2019-02":"a #1 #2 #4","2021-10":"a #1 #2"}},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #5"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"a #1 #5 #6"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"a #1 #5 #6"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Does not support multiple values. The comma between two values is removed.","2":"Partial. Does not support the `/ value` shorthand for `background-size`. But it can be used in the `background-size` property instead.","3":"Partial. Only `background-color` values are supported.","4":"Partial. Images URL must be between quotes.","5":"Partial. Does not support multiple values. The entire property is removed if so.","6":"Partial. Does not support the `/ value` shorthand for `background-size`.","7":"Partial and buggy. Removes the entire `style` attribute or `<style>` tag when a `url()` function with a valid image URL is present. See [Gmail rolling out changes that strip CSS with background images](https://freshinbox.com/blog/gmail-rolling-out-changes-that-strip-background-image-css/) and [Gmail and background images](https://parcel.io/blog/gmail-and-background-images)."}
},
@@ -1262,7 +1262,7 @@
"last_test_date":"2019-08-02",
"test_url":"https://www.caniemail.com/tests/css-width-height.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/dP8XNPcCLZGrogYGvFgCRRjJJO2nTWxchQ0WZSu0Pxcyb/list",
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"5.1":"n #2","6.1":"n #2","10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2003":"a #2","2007":"n","2010":"n","2013":"n","2016":"n","2019":"y #1"},"windows-mail":{"2020-01":"y #1"},"macos":{"2011":"y","2016":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"a #2"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"a #2"},"android":{"2019-08":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"a #2"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"5.1":"a #2","6.1":"a #2","10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2003":"a #2","2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-01":"a #1"},"macos":{"2011":"y","2016":"y"},"outlook-com":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"a #2"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"a #2"},"android":{"2019-08":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"a #2"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Only works on `<table>` elements.","2":"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths)."}
},
@@ -1347,6 +1347,22 @@
"notes_by_num":{"1":"Depends on browser support.","2":"Using this syntax for an inline style will remove all inline styles applied to that element."}
},
{
"slug":"css-nesting",
"title":"CSS Nesting",
"description":"A syntax for nesting selectors, providing the ability to nest one style rule inside another.",
"url":"https://www.caniemail.com/features/css-nesting/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2023-08-31",
"test_url":"https://www.caniemail.com/tests/css-nesting.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list",
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1"},"outlook-com":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"samsung-email":{"android":{"6.0":"u"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n"}},"aol":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2023-08":"u"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2023-08":"u"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `E { F {}}` doesnt work, but `E { & F {}}` does.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nest properties apply to the parent selector."}
},
{
"slug":"css-object-fit",
"title":"object-fit",
@@ -1630,7 +1646,7 @@
"last_test_date":"2022-03-15",
"test_url":"https://www.caniemail.com/tests/css-has.html",
"test_results_url":"",
"stats":{"apple-mail":{"macos":{"15.0":"n","16.0":"y"},"ios":{"15.1":"n","15.4":"y"}},"gmail":{"desktop-webmail":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"},"mobile-webmail":{"2021-12":"n"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-12":"n"},"macos":{"16.56":"n"},"outlook-com":{"2021-12":"n"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"samsung-email":{"android":{"6.0":"n"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"thunderbird":{"macos":{"78.14":"n"}},"aol":{"desktop-webmail":{"2021-12":"n #1"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n #1"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"n #2"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"n"}},"fastmail":{"desktop-webmail":{"2021-12":"n"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"stats":{"apple-mail":{"macos":{"15.0":"n","16.0":"y"},"ios":{"15.1":"n","15.4":"y"}},"gmail":{"desktop-webmail":{"2021-12":"n","2023-09":"n"},"ios":{"2021-12":"n","2023-09":"n"},"android":{"2021-12":"n","2023-09":"n"},"mobile-webmail":{"2021-12":"n"}},"orange":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2021-12":"n"},"macos":{"16.56":"n","16.73":"y"},"outlook-com":{"2021-12":"n","2023-09":"n"},"ios":{"2021-12":"n","2023-09":"n"},"android":{"2021-12":"n"}},"samsung-email":{"android":{"6.0":"n","6.1.82":"y"}},"sfr":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"thunderbird":{"macos":{"78.14":"n","115.2":"n"}},"aol":{"desktop-webmail":{"2021-12":"n #1"},"ios":{"2021-12":"n"},"android":{"2021-12":"n"}},"yahoo":{"desktop-webmail":{"2021-12":"n #1","2023-09":"n #1"},"ios":{"2021-12":"n","2023-09":"n"},"android":{"2021-12":"n","2023-09":"n"}},"protonmail":{"desktop-webmail":{"2021-12":"n #2","2023-09":"y"},"ios":{"2021-12":"n","2023-09":"y"},"android":{"2021-12":"n","2023-09":"n"}},"hey":{"desktop-webmail":{"2021-12":"y"}},"mail-ru":{"desktop-webmail":{"2021-12":"n","2023-09":"n"}},"fastmail":{"desktop-webmail":{"2021-12":"n","2023-09":"y"}},"laposte":{"desktop-webmail":{"2021-12":"y"}},"gmx":{"desktop-webmail":{"2022-07":"n","2023-09":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"notes":"As of december 2021, `:has()` is only supported in [Safari Technology Preview 137](https://webkit.org/blog/12156/release-notes-for-safari-technology-preview-137/). As of march 2022, it is supported in Safari 15.4.",
"notes_by_num":{"1":"Not supported. `:has(…)` is replaced by `:has`.","2":"Not supported. But the pseudo-class seems interpreted and computed server side."}
},
@@ -2751,8 +2767,8 @@
"test_url":"https://www.caniemail.com/tests/css-vertical-align-html-valign.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/XDUBIjG7AOXLUwfUUDYDO68OO1POjklmaeeqkOeSylkJL/list",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"},"mobile-webmail":{"2020-12":"y"}},"orange":{"desktop-webmail":{"2020-12":"y","2021-03":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"outlook":{"windows":{"2003":"y","2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2020-12":"y"},"macos":{"2016":"y"},"outlook-com":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"thunderbird":{"macos":{"78.6":"y"}},"aol":{"desktop-webmail":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"yahoo":{"desktop-webmail":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"protonmail":{"desktop-webmail":{"2020-12":"y"},"ios":{"2020-12":"y"},"android":{"2020-12":"y"}},"hey":{"desktop-webmail":{"2020-12":"y"}},"mail-ru":{"desktop-webmail":{"2020-12":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":"This is a global note.",
"notes_by_num":{"1":"Partial. Fixed attachment is not supported.","2":"Partial. Slash syntax values are not supported.","3":"Partial. Values containing background images are not supported.","4":"Buggy. For slash syntax values, it removes the slash character, making the value invalid.","5":"Partial. Seems to only support background colors."}
"notes":null,
"notes_by_num":null
},
{
@@ -3755,10 +3771,10 @@
"category":"html",
"tags":[],
"keywords":null,
"last_test_date":"2019-06-24",
"last_test_date":"2023-07-27",
"test_url":"https://www.caniemail.com/tests/html-style.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/od5IYQtx8yIbIUbeRyQXnP0yzFKEm2E9CKa3FU4BcEXFv/list",
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y"},"outlook-com":{"2019-06":"y","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}}},
"stats":{"apple-mail":{"macos":{"10.3":"y","16.0":"y"},"ios":{"10.3":"y","12.3":"y","16.2":"y"}},"gmail":{"desktop-webmail":{"2019-06":"a #1","2023-01":"a #1"},"ios":{"2019-06":"a #1 #2","2023-01":"a #1 #2"},"android":{"2019-06":"a #1 #2"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"orange":{"desktop-webmail":{"2019-06":"y","2021-03":"y","2023-02":"y"},"ios":{"2019-06":"y","2023-02":"y"},"android":{"2019-06":"n","2019-08":"y","2023-02":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #4","2010":"a #4","2013":"a #4","2016":"a #4","2019":"a #4"},"windows-mail":{"2020-01":"a #4","2023-01":"a #4"},"macos":{"2011":"y","2016":"y","2019":"y","2021":"y"},"outlook-com":{"2019-06":"y","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"y"}},"samsung-email":{"android":{"5.0.10.2":"y","6.0":"y"}},"sfr":{"desktop-webmail":{"2019-06":"y","2023-02":"y"},"ios":{"2019-06":"n","2023-02":"n"},"android":{"2019-06":"n","2023-02":"n"}},"thunderbird":{"macos":{"60.3":"y","102.7":"y"},"windows":{"102.7":"y"}},"aol":{"desktop-webmail":{"2020-01":"y","2023-01":"y"},"ios":{"2020-01":"y","2023-01":"y"},"android":{"2020-01":"y"}},"yahoo":{"desktop-webmail":{"2019-06":"y #5","2023-01":"y"},"ios":{"2019-06":"y","2023-01":"y"},"android":{"2019-06":"a #3","2023-01":"a #3"}},"protonmail":{"desktop-webmail":{"2020-03":"n","2022-02":"y","2023-01":"y"},"ios":{"2020-03":"n","2023-01":"a #1"},"android":{"2020-03":"a #1"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-09":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"},"ios":{"2023-02":"n"},"android":{"2023-02":"n"}},"gmx":{"desktop-webmail":{"2022-11":"y","2023-07":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}},"rainloop":{"desktop-webmail":{"2023-02":"n"}}},
"notes":"",
"notes_by_num":{"1":"Partial. Not supported inside the `<body>`.","2":"Partial. Not supported with Non Gmail Accounts.","3":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","4":"Buggy. `<style>` elements need to be declared before their rules are used.","5":"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)"}
},

View File

@@ -10,7 +10,8 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/vanng822/go-premailer/premailer"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -59,7 +60,7 @@ func runCSSTests(html string) ([]Warning, int, error) {
// get a list of all generated styles from all nodes
allNodeStyles := []string{}
for _, n := range doc.Find("*[style]").Nodes {
style, err := getHTMLAttributeVal(n, "style")
style, err := tools.GetHTMLAttributeVal(n, "style")
if err == nil {
allNodeStyles = append(allNodeStyles, style)
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html"
"github.com/axllent/mailpit/internal/tools"
)
// HTML tests
@@ -72,7 +72,7 @@ func runHTMLTests(html string) ([]Warning, int, error) {
totalTests = totalTests + len(imageRegexpTests)
for _, image := range images {
src, err := getHTMLAttributeVal(image, "src")
src, err := tools.GetHTMLAttributeVal(image, "src")
if err != nil {
continue
}
@@ -100,13 +100,3 @@ func runHTMLTests(html string) ([]Warning, int, error) {
return results, totalTests, nil
}
func getHTMLAttributeVal(e *html.Node, key string) (string, error) {
for _, a := range e.Attr {
if a.Key == key {
return a.Val, nil
}
}
return "", nil
}

View File

@@ -0,0 +1,92 @@
// Package linkcheck handles message links checking
package linkcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
)
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
// RunTests will run all tests on an HTML string
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
s := Response{}
allLinks := extractHTMLLinks(msg)
allLinks = strUnique(append(allLinks, extractTextLinks(msg)...))
s.Links = getHTTPStatuses(allLinks, followRedirects)
for _, l := range s.Links {
if l.StatusCode >= 400 || l.StatusCode == 0 {
s.Errors++
}
}
return s, nil
}
func extractTextLinks(msg *storage.Message) []string {
links := []string{}
for _, match := range linkRe.FindAllString(msg.Text, -1) {
links = append(links, match)
}
return links
}
func extractHTMLLinks(msg *storage.Message) []string {
links := []string{}
reader := strings.NewReader(msg.HTML)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return links
}
aLinks := doc.Find("a[href]").Nodes
for _, link := range aLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range cssLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
imgLinks := doc.Find("img[src]").Nodes
for _, link := range imgLinks {
l, err := tools.GetHTMLAttributeVal(link, "src")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
return links
}
// strUnique return a slice of unique strings from a slice
func strUnique(strSlice []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range strSlice {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}

View File

@@ -0,0 +1,106 @@
package linkcheck
import (
"net/http"
"regexp"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
func getHTTPStatuses(links []string, followRedirects bool) []Link {
// allow 5 threads
threads := make(chan int, 5)
results := make(map[string]Link, len(links))
resultsMutex := sync.RWMutex{}
output := []Link{}
var wg sync.WaitGroup
for _, l := range links {
wg.Add(1)
go func(link string, w *sync.WaitGroup) {
threads <- 1 // will block if MAX threads
defer w.Done()
code, err := doHead(link, followRedirects)
l := Link{}
l.URL = link
if err != nil {
l.StatusCode = 0
l.Status = httpErrorSummary(err)
} else {
l.StatusCode = code
l.Status = http.StatusText(code)
}
resultsMutex.Lock()
results[link] = l
resultsMutex.Unlock()
<-threads // remove from threads
}(l, &wg)
}
wg.Wait()
for _, l := range results {
output = append(output, l)
}
return output
}
// Do a HEAD request to return HTTP status code
func doHead(link string, followRedirects bool) (int, error) {
timeout := time.Duration(10 * time.Second)
client := http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if followRedirects {
return nil
}
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest("HEAD", link, nil)
if err != nil {
logger.Log().Error(err)
return 0, err
}
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
res, err := client.Do(req)
if err != nil {
if res != nil {
return res.StatusCode, err
}
return 0, err
}
return res.StatusCode, nil
}
// HTTP errors include a lot more info that just the actual error, so this
// tries to take the final part of it, eg: `no such host`
func httpErrorSummary(err error) string {
var re = regexp.MustCompile(`.*: (.*)$`)
e := err.Error()
if !re.MatchString(e) {
return e
}
parts := re.FindAllStringSubmatch(e, -1)
return parts[0][len(parts[0])-1]
}

View File

@@ -0,0 +1,21 @@
package linkcheck
// Response represents the Link check response
//
// swagger:model LinkCheckResponse
type Response struct {
// Total number of errors
Errors int `json:"Errors"`
// Tested links
Links []Link `json:"Links"`
}
// Link struct
type Link struct {
// Link URL
URL string `json:"URL"`
// HTTP status code
StatusCode int `json:"StatusCode"`
// HTTP status definition
Status string `json:"Status"`
}

View File

@@ -18,15 +18,14 @@ import (
"syscall"
"time"
"github.com/GuiaBolso/darwin"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/google/uuid"
"github.com/jhillyerd/enmime"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
"github.com/mattn/go-shellwords"
uuid "github.com/satori/go.uuid"
// sqlite (native) - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite"
@@ -43,81 +42,8 @@ var (
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbDecoder, _ = zstd.NewReader(nil)
dbMigrations = []darwin.Migration{
{
Version: 1.0,
Description: "Creating tables",
Script: `CREATE TABLE IF NOT EXISTS mailbox (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE TABLE IF NOT EXISTS mailbox_data (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
},
{
Version: 1.1,
Description: "Create tags column",
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.2,
Description: "Creating new mailbox format",
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO mailboxtmp
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM mailbox;
DROP TABLE IF EXISTS mailbox;
ALTER TABLE mailboxtmp RENAME TO mailbox;
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
}
)
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
}
// InitDB will initialise the database
func InitDB() error {
p := config.DataFile
@@ -179,15 +105,6 @@ func InitDB() error {
return nil
}
// Create tables and apply migrations if required
func dbApplyMigrations() error {
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
d := darwin.New(driver, dbMigrations, nil)
return d.Migrate()
}
// Close will close the database, and delete if a temporary table
func Close() {
if db != nil {
@@ -204,7 +121,8 @@ func Close() {
}
}
// Store will save an email to the database tables
// Store will save an email to the database tables.
// Returns the database ID of the saved message.
func Store(body []byte) (string, error) {
// Parse message body with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
@@ -243,7 +161,7 @@ func Store(body []byte) (string, error) {
searchText := createSearchText(env)
// generate unique ID
id := uuid.NewV4().String()
id := uuid.New().String()
summaryJSON, err := json.Marshal(obj)
if err != nil {
@@ -281,10 +199,11 @@ func Store(body []byte) (string, error) {
size := len(body)
inline := len(env.Inlines)
attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML)
// insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read) values(?,?,?,?,?,?,?,?,?,?,0)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON))
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read, Snippet) values(?,?,?,?,?,?,?,?,?,?,0, ?)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON), snippet)
if err != nil {
return "", err
}
@@ -312,11 +231,14 @@ func Store(body []byte) (string, error) {
c.Subject = subject
c.Size = size
c.Tags = tagData
c.Snippet = snippet
websockets.Broadcast("new", c)
dbLastAction = time.Now()
BroadcastMailboxStats()
return id, nil
}
@@ -326,7 +248,7 @@ func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Snippet`).
OrderBy("Created DESC").
Limit(limit).
Offset(start)
@@ -341,9 +263,10 @@ func List(start, limit int) ([]MessageSummary, error) {
var attachments int
var tags string
var read int
var snippet string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet); err != nil {
logger.Log().Error(err)
return
}
@@ -365,11 +288,9 @@ func List(start, limit int) ([]MessageSummary, error) {
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
results = append(results, em)
// logger.PrettyPrint(em)
}); err != nil {
return results, err
}
@@ -379,96 +300,6 @@ func List(start, limit int) ([]MessageSummary, error) {
return results, nil
}
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
s := strings.ToLower(search)
// add another quote if missing closing quote
quotes := strings.Count(s, `"`)
if quotes%2 != 0 {
s += `"`
}
p := shellwords.NewParser()
args, err := p.Parse(s)
if err != nil {
return results, nrResults, errors.New("Your search contains invalid characters")
}
// generate the SQL based on arguments
q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// GetMessage returns a Message generated from the mailbox_data collection.
// If the message lacks a date header, then the received datetime is used.
func GetMessage(id string) (*Message, error) {
@@ -525,7 +356,6 @@ func GetMessage(id string) (*Message, error) {
obj := Message{
ID: id,
MessageID: messageID,
Read: true,
From: from,
Date: date,
To: addressToSlice(env, "To"),
@@ -651,6 +481,8 @@ func MarkRead(id string) error {
logger.Log().Debugf("[db] marked message %s as read", id)
}
BroadcastMailboxStats()
return err
}
@@ -672,6 +504,8 @@ func MarkAllRead() error {
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
@@ -695,6 +529,8 @@ func MarkAllUnread() error {
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
@@ -717,6 +553,8 @@ func MarkUnread(id string) error {
dbLastAction = time.Now()
BroadcastMailboxStats()
return err
}
@@ -751,6 +589,8 @@ func DeleteOneMessage(id string) error {
dbLastAction = time.Now()
dbDataDeleted = true
BroadcastMailboxStats()
return err
}
@@ -799,19 +639,13 @@ func DeleteAllMessages() error {
dbDataDeleted = false
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
return err
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
total = CountTotal()
unread = CountUnread()
)
dbLastAction = time.Now()
// GetAllTags returns all used tags
func GetAllTags() []string {
q := sqlf.From("mailbox").
Select(`DISTINCT Tags`).
Where("Tags != ?", "[]")
@@ -844,6 +678,19 @@ func StatsGet() MailboxStats {
sort.Strings(tags)
return tags
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
total = CountTotal()
unread = CountUnread()
tags = GetAllTags()
)
dbLastAction = time.Now()
return MailboxStats{
Total: total,
Unread: unread,

View File

@@ -1,22 +1,8 @@
package storage
import (
"bytes"
"fmt"
"io/ioutil"
"math/rand"
"testing"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
var (
testTextEmail []byte
testMimeEmail []byte
testRuns = 100
)
func TestTextEmailInserts(t *testing.T) {
@@ -63,8 +49,6 @@ func TestMimeEmailInserts(t *testing.T) {
start := time.Now()
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
@@ -87,8 +71,6 @@ func TestMimeEmailInserts(t *testing.T) {
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
assertEqualStats(t, 0, 0)
}
func TestRetrieveMimeEmail(t *testing.T) {
@@ -110,11 +92,11 @@ func TestRetrieveMimeEmail(t *testing.T) {
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender@example.com", "\"From\" address does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient@example.com", "\"To\" address does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
@@ -135,74 +117,36 @@ func TestRetrieveMimeEmail(t *testing.T) {
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
}
func TestSearch(t *testing.T) {
func TestMessageSummary(t *testing.T) {
setup()
defer Close()
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
t.Log("Testing message summary")
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
for i := 1; i < 51; i++ {
// search a random something that will return a single result
searchIdx := rand.Intn(4) + 1
var search string
switch searchIdx {
case 1:
search = fmt.Sprintf("from-%d@example.com", i)
case 2:
search = fmt.Sprintf("to-%d@example.com", i)
case 3:
search = fmt.Sprintf("\"Subject line %d end\"", i)
default:
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
}
summaries, _, err := Search(search, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "1 search result expected")
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", 0, testRuns)
summaries, err := List(0, 1)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
assertEqual(t, len(summaries), 1, "Expected 1 result")
msg := summaries[0]
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
}
func BenchmarkImportText(b *testing.B) {
@@ -229,44 +173,3 @@ func BenchmarkImportMime(b *testing.B) {
}
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := InitDB(); err != nil {
panic(err)
}
var err error
testTextEmail, err = ioutil.ReadFile("testdata/plain-text.eml")
if err != nil {
panic(err)
}
testMimeEmail, err = ioutil.ReadFile("testdata/mime-attachment.eml")
if err != nil {
panic(err)
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if total != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
}
if unread != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
}
}

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
"golang.org/x/text/language"

View File

@@ -0,0 +1,84 @@
package storage
import "github.com/GuiaBolso/darwin"
var (
dbMigrations = []darwin.Migration{
{
Version: 1.0,
Description: "Creating tables",
Script: `CREATE TABLE IF NOT EXISTS mailbox (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE TABLE IF NOT EXISTS mailbox_data (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
},
{
Version: 1.1,
Description: "Create tags column",
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.2,
Description: "Creating new mailbox format",
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO mailboxtmp
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM mailbox;
DROP TABLE IF EXISTS mailbox;
ALTER TABLE mailboxtmp RENAME TO mailbox;
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.3,
Description: "Create snippet column",
Script: `ALTER TABLE mailbox ADD COLUMN Snippet Text NOT NULL DEFAULT '';`,
},
}
)
// Create tables and apply migrations if required
func dbApplyMigrations() error {
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
d := darwin.New(driver, dbMigrations, nil)
return d.Migrate()
}

View File

@@ -0,0 +1,35 @@
package storage
import (
"time"
"github.com/axllent/mailpit/server/websockets"
)
var bcStatsDelay = false
// BroadcastMailboxStats broadcasts the total number of messages
// displayed to the web UI, as well as the total unread messages.
// The lookup is very fast (< 10ms / 100k messages under load).
// Rate limited to 4x per second.
func BroadcastMailboxStats() {
if bcStatsDelay {
return
}
bcStatsDelay = true
go func() {
time.Sleep(250 * time.Millisecond)
bcStatsDelay = false
b := struct {
Total int
Unread int
}{
Total: CountTotal(),
Unread: CountUnread(),
}
websockets.Broadcast("stats", b)
}()
}

184
internal/storage/reindex.go Normal file
View File

@@ -0,0 +1,184 @@
package storage
import (
"bytes"
"context"
"database/sql"
"os"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
)
// ReindexAll will regenerate the search text and snippet for a message
// and update the database.
func ReindexAll() {
ids := []string{}
var i string
chunkSize := 1000
finished := 0
err := sqlf.Select("ID").To(&i).
From("mailbox").
OrderBy("Created DESC").
QueryAndClose(nil, db, func(row *sql.Rows) {
ids = append(ids, i)
})
if err != nil {
logger.Log().Error(err)
os.Exit(1)
}
total := len(ids)
chunks := chunkBy(ids, chunkSize)
logger.Log().Infof("Reindexing %d messages", total)
// fmt.Println(len(ids), " = ", len(chunks), "chunks")
type updateStruct struct {
ID string
SearchText string
Snippet string
}
for _, ids := range chunks {
updates := []updateStruct{}
for _, id := range ids {
raw, err := GetMessageRaw(id)
if err != nil {
logger.Log().Error(err)
continue
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
logger.Log().Error(err)
continue
}
searchText := createSearchText(env)
snippet := tools.CreateSnippet(env.Text, env.HTML)
u := updateStruct{}
u.ID = id
u.SearchText = searchText
u.Snippet = snippet
updates = append(updates, u)
}
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Error(err)
continue
}
// roll back if it fails
defer tx.Rollback()
// insert mail summary data
for _, u := range updates {
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ? WHERE ID = ?", u.SearchText, u.Snippet, u.ID)
if err != nil {
logger.Log().Error(err)
continue
}
}
if err := tx.Commit(); err != nil {
logger.Log().Error(err)
continue
}
finished += len(updates)
logger.Log().Printf("Reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
}
}
// Reindex will regenerate the search text and snippet for a message
// and update the database.
func Reindex(id string) error {
// ids := []string{}
// var i string
// // chunkSize := 100
// err := sqlf.Select("ID").To(&i).From("mailbox_data").QueryAndClose(nil, db, func(row *sql.Rows) {
// ids = append(ids, id)
// })
// if err != nil {
// return err
// }
// chunks := chunkBy(ids, 100)
// fmt.Println(len(ids), " = ", len(chunks), "chunks")
// return nil
raw, err := GetMessageRaw(id)
if err != nil {
return err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return err
}
searchText := createSearchText(env)
snippet := tools.CreateSnippet(env.Text, env.HTML)
// return nil
// ctx := context.Background()
// tx, err := db.BeginTx(ctx, nil)
// if err != nil {
// return err
// }
// // roll back if it fails
// defer tx.Rollback()
// // insert mail summary data
// _, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ? WHERE ID = ?", searchText, snippet, id)
// if err != nil {
// return err
// }
// return tx.Commit()
_, err = sqlf.Update("mailbox").
Set("SearchText", searchText).
Set("Snippet", snippet).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
return err
}
// ctx := context.Background()
// tx, err := db.BeginTx(ctx, nil)
// if err != nil {
// return "", err
// }
func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
for chunkSize < len(items) {
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
}
return append(chunks, items)
}

321
internal/storage/search.go Normal file
View File

@@ -0,0 +1,321 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
q := searchQueryBuilder(search)
var err error
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var snippet string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// DeleteSearch will delete all messages for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func DeleteSearch(search string) error {
q := searchQueryBuilder(search)
ids := []string{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var snippet string
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
ids = append(ids, id)
}); err != nil {
return err
}
if len(ids) > 0 {
total := len(ids)
// split ids into chunks of 1000 ids
var chunks [][]string
if total > 1000 {
chunkSize := 1000
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
for chunkSize < len(ids) {
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
}
if len(ids) > 0 {
// add remaining ids <= 1000
chunks = append(chunks, ids)
}
} else {
chunks = append(chunks, ids)
}
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
for _, ids := range chunks {
delIDs := make([]interface{}, len(ids))
for i, id := range ids {
delIDs[i] = id
}
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
_, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil {
return err
}
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
_, err = tx.Exec(sqlDelete2, delIDs...)
if err != nil {
return err
}
}
err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
}
dbLastAction = time.Now()
dbDataDeleted = true
BroadcastMailboxStats()
}
return nil
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString string) *sqlf.Stmt {
searchString = strings.ToLower(searchString)
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Snippet,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON
`).OrderBy("Created DESC")
for _, w := range args {
if cleanString(w) == "" {
continue
}
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
continue
}
if strings.HasPrefix(w, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "cc:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "bcc:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "subject:") {
w = w[8:]
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "message-id:") {
w = cleanString(w[11:])
if w != "" {
if exclude {
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "tag:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%")
} else {
q.Where("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if w == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if w == "is:tagged" {
if exclude {
q.Where("Tags = ?", "[]")
} else {
q.Where("Tags != ?", "[]")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else {
// search text
if exclude {
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}
return q
}

View File

@@ -0,0 +1,152 @@
package storage
import (
"bytes"
"fmt"
"math/rand"
"testing"
"github.com/jhillyerd/enmime"
)
func TestSearch(t *testing.T) {
setup()
defer Close()
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 51; i++ {
// search a random something that will return a single result
searchIdx := rand.Intn(4) + 1
var search string
switch searchIdx {
case 1:
search = fmt.Sprintf("from-%d@example.com", i)
case 2:
search = fmt.Sprintf("to-%d@example.com", i)
case 3:
search = fmt.Sprintf("\"Subject line %d end\"", i)
default:
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
}
summaries, _, err := Search(search, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "1 search result expected")
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
}
func TestSearchDelete100(t *testing.T) {
setup()
defer Close()
t.Log("Testing search delete of 100 messages")
for i := 0; i < 100; i++ {
if _, err := Store(testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}
func TestSearchDelete1100(t *testing.T) {
setup()
defer Close()
t.Log("Testing search delete of 1100 messages")
for i := 0; i < 1100; i++ {
if _, err := Store(testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 1100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}

View File

@@ -15,8 +15,6 @@ type Message struct {
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
From *mail.Address
// To addresses
@@ -91,6 +89,8 @@ type MessageSummary struct {
Size int
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
Snippet string
}
// MailboxStats struct for quick mailbox total/read lookups
@@ -100,6 +100,14 @@ type MailboxStats struct {
Tags []string
}
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}

View File

@@ -7,8 +7,8 @@ import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)

View File

@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"testing"
)
func TestTags(t *testing.T) {
setup()
defer Close()
t.Log("Testing tags")
ids := []string{}
for i := 0; i < 10; i++ {
id, err := Store(testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
ids = append(ids, id)
}
for i := 0; i < 10; i++ {
if err := SetTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 0; i < 10; i++ {
message, err := GetMessage(ids[i])
if err != nil {
t.Log("error ", err)
t.Fail()
}
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
t.Fatal("Message tags do not match")
}
}
}

View File

@@ -1,4 +1,4 @@
Delivered-To: recipient@example.com
Delivered-To: recipient2@example.com
Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs;
Tue, 26 Jul 2022 20:42:36 -0700 (PDT)
X-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881;
@@ -23,18 +23,18 @@ ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc
uSfA==
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
Return-Path: <sender@example.com>
Return-Path: <sender2@example.com>
Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])
by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35
for <recipient@example.com>
for <recipient2@example.com>
(Google Transport Security);
Tue, 26 Jul 2022 20:42:35 -0700 (PDT)
Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
Received-SPF: pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
Authentication-Results: mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20210112;
@@ -63,10 +63,10 @@ X-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77
X-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ==
X-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675;
Tue, 26 Jul 2022 20:42:34 -0700 (PDT)
Return-Path: <sender@example.com>
Return-Path: <sender2@example.com>
Received: from [192.168.1.2] ([8.8.8.8])
by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32
for <recipient@example.com>
for <recipient2@example.com>
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
Tue, 26 Jul 2022 20:42:33 -0700 (PDT)
Content-Type: multipart/mixed; boundary="------------ae0qIOkrNQLQHe1YyfTsUXrk"
@@ -76,8 +76,8 @@ MIME-Version: 1.0
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
Thunderbird/91.11.0
Content-Language: en-NZ
To: "Recipient Ross" <recipient@example.com>
From: Sender Smith <sender@example.com>
To: "Recipient Ross" <recipient2@example.com>
From: Sender Smith <sender2@example.com>
Subject: inline + attachment
This is a multi-part message in MIME format.
@@ -108,10 +108,9 @@ Content-Transfer-Encoding: 7bit
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body>
Message with inline image and attachment:<br>
<h1>Message with inline image and attachment:</h1>
<br>
<img src="cid:part1.845LaYlX.wtWMpWwa@gmail.com"
moz-do-not-send="false"><br>
<p><img src="cid:part1.845LaYlX.wtWMpWwa@gmail.com"></p>
<br>
<br>
</body>

View File

@@ -0,0 +1,57 @@
package storage
import (
"fmt"
"os"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
var (
testTextEmail []byte
testMimeEmail []byte
testRuns = 100
)
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := InitDB(); err != nil {
panic(err)
}
var err error
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
if err != nil {
panic(err)
}
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
if err != nil {
panic(err)
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if total != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
}
if unread != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
}
}

View File

@@ -10,10 +10,10 @@ import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools/html2text"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/leporo/sqlf"
)
@@ -37,12 +37,10 @@ func createSearchText(env *enmime.Envelope) string {
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
h := strings.TrimSpace(
html2text.HTML2TextWithOptions(
env.HTML,
html2text.WithLinksInnerText(),
),
)
b.WriteString(env.GetHeader("Reply-To") + " ")
b.WriteString(env.GetHeader("Return-Path") + " ")
h := html2text.Strip(env.HTML, true)
if h != "" {
b.WriteString(h + " ")
} else {
@@ -60,8 +58,11 @@ func createSearchText(env *enmime.Envelope) string {
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
str = strings.ReplaceAll(str, string('\uFEFF'), " ")
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;)`)
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
@@ -183,3 +184,42 @@ func inArray(k string, arr []string) bool {
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}
// Escape certain characters in search phrases
func escSearch(str string) string {
dest := make([]byte, 0, 2*len(str))
var escape byte
for i := 0; i < len(str); i++ {
c := str[i]
escape = 0
switch c {
case 0: /* Must be escaped for 'mysql' */
escape = '0'
break
case '\n': /* Must be escaped for logs */
escape = 'n'
break
case '\r':
escape = 'r'
break
case '\\':
escape = '\\'
break
case '\'':
escape = '\''
break
case '\032': //十进制26,八进制32,十六进制1a, /* This gives problems on Win32 */
escape = 'Z'
}
if escape != 0 {
dest = append(dest, '\\', escape)
} else {
dest = append(dest, c)
}
}
return string(dest)
}

View File

@@ -0,0 +1,32 @@
package tools
import "strings"
// ArgsParser will split a string by new words and quotes phrases
func ArgsParser(s string) []string {
args := []string{}
sb := &strings.Builder{}
quoted := false
for _, r := range s {
if r == '"' {
quoted = !quoted
sb.WriteRune(r) // keep '"' otherwise comment this line
} else if !quoted && r == ' ' {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
sb.Reset()
} else {
sb.WriteRune(r)
}
}
if sb.Len() > 0 {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
}
return args
}

19
internal/tools/html.go Normal file
View File

@@ -0,0 +1,19 @@
package tools
import (
"fmt"
"golang.org/x/net/html"
)
// GetHTMLAttributeVal returns the value of an HTML Attribute, else an error.
// Returns a blank value if the attribute is set but empty.
func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
for _, a := range e.Attr {
if a.Key == key {
return a.Val, nil
}
}
return "", fmt.Errorf("%s not found", key)
}

View File

@@ -0,0 +1,82 @@
// Package html2text is a simple library to convert HTML to plain text
package html2text
import (
"bytes"
"log"
"regexp"
"strings"
"unicode"
"golang.org/x/net/html"
)
var (
re = regexp.MustCompile(`\s+`)
spaceRe = regexp.MustCompile(`(?mi)<\/(div|p|td|th|h[1-6]|ul|ol|li|address|article|aside|blockquote|dl|dt|footer|header|hr|main|nav|pre|table|thead|tfoot|video)><`)
brRe = regexp.MustCompile(`(?mi)<(br /|br)>`)
imgRe = regexp.MustCompile(`(?mi)<(img)`)
skip = make(map[string]bool)
)
func init() {
skip["script"] = true
skip["title"] = true
skip["head"] = true
skip["link"] = true
skip["meta"] = true
skip["style"] = true
skip["noscript"] = true
}
// Strip will convert a HTML string to plain text
func Strip(h string, includeLinks bool) string {
h = spaceRe.ReplaceAllString(h, "</$1> <")
h = brRe.ReplaceAllString(h, " ")
h = imgRe.ReplaceAllString(h, " <$1")
var buffer bytes.Buffer
doc, err := html.Parse(strings.NewReader(h))
if err != nil {
log.Fatal(err)
}
extract(doc, &buffer, includeLinks)
return clean(buffer.String())
}
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
if node.Type == html.TextNode {
data := node.Data
if data != "" {
buff.WriteString(data)
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
if _, skip := skip[c.Data]; !skip {
if includeLinks && c.Data == "a" {
for _, a := range c.Attr {
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
buff.WriteString(" " + a.Val + " ")
}
}
}
extract(c, buff, includeLinks)
}
}
}
func clean(text string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
// remove non-printable characters
text = strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return []rune(" ")[0]
}, text)
text = re.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,250 @@
package html2text
import "testing"
func TestPlain(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
tests["<h1>This is a test.</h1> "] = "This is a test."
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
for str, expected := range tests {
res := Strip(str, false)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()
}
}
}
func TestWithLinks(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
tests["<h1>This is a test.</h1> "] = "This is a test."
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
for str, expected := range tests {
res := Strip(str, true)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()
}
}
}
func BenchmarkPlain(b *testing.B) {
for i := 0; i < b.N; i++ {
Strip(htmlTestData, false)
}
}
func BenchmarkLinks(b *testing.B) {
for i := 0; i < b.N; i++ {
Strip(htmlTestData, true)
}
}
var htmlTestData = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="font-family: sans-serif; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; box-sizing: border-box;" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<title>[axllent/mailpit] Run failed: .github/workflows/tests.yml - feature/swagger (284335a)</title>
</head>
<body style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;; font-size: 14px; line-height: 1.5; color: #24292e; background-color: #fff; margin: 0;" bgcolor="#fff">
<table align="center" class="container-sm width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 544px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="center p-3" align="center" valign="top" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 16px;">
<center style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full container-md" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 768px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="left" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="text-left" style="box-sizing: border-box; text-align: left !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;" align="left">
<img src="https://github.githubassets.com/images/email/global/octocat-logo.png" alt="GitHub" width="32" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;" />
<h2 class="lh-condensed mt-2 text-normal" style="box-sizing: border-box; margin-top: 8px !important; margin-bottom: 0; font-size: 24px; font-weight: 400 !important; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
[axllent/mailpit] .github/workflows/tests.yml workflow run
</h2>
</td>
</tr>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
<table width="100%" class="width-full" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="border rounded-2 d-block" style="box-sizing: border-box; border-radius: 6px !important; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0; border: 1px solid #e1e4e8;">
<table align="center" class="width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table align="center" class="border-bottom width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; border-bottom-width: 1px !important; border-bottom-color: #e1e4e8 !important; border-bottom-style: solid !important; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="d-block px-3 pt-3 p-sm-4" style="box-sizing: border-box; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 24px;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<img src="https://github.githubassets.com/images/email/icons/actions.png" width="56" height="56" alt="" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;" />
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="12" style="font-size: 12px; line-height: 12px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<h3 class="lh-condensed" style="box-sizing: border-box; margin-top: 0; margin-bottom: 0; font-size: 20px; font-weight: 600; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">.github/workflows/tests.yml: No jobs were run</h3>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table border="0" cellspacing="0" cellpadding="0" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<!--[if mso]> <table><tr><td align="center" bgcolor="#28a745"> <![endif]-->
<a href="https://github.com/axllent/mailpit/actions/runs/6522820865" target="_blank" rel="noopener noreferrer" class="btn btn-large btn-primary" style="background-color: #1f883d !important; box-sizing: border-box; color: #fff; text-decoration: none; position: relative; display: inline-block; font-size: inherit; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: middle; cursor: pointer; -webkit-user-select: none; user-select: none; border-radius: .5em; -webkit-appearance: none; appearance: none; box-shadow: 0 1px 0 rgba(27,31,35,.1),inset 0 1px 0 rgba(255,255,255,.03); transition: background-color .2s cubic-bezier(0.3, 0, 0.5, 1); font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: .75em 1.5em; border: 1px solid #1f883d;">View workflow run</a>
<!--[if mso]> </td></tr></table> <![endif]-->
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="32" style="font-size: 32px; line-height: 32px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<p class="f5 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 14px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;"> </p><p style="font-size: small; -webkit-text-size-adjust: none; color: #666; box-sizing: border-box; margin-top: 0; margin-bottom: 10px; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">&#8212;<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;" />You are receiving this because you are subscribed to this thread.<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;" /><a href="https://github.com/settings/notifications" style="background-color: transparent; box-sizing: border-box; color: #0366d6; text-decoration: none; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">Manage your GitHub Actions notifications</a></p>
</td>
</tr>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<p class="f6 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 12px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">GitHub, Inc. &#12539;88 Colin P Kelly Jr Street &#12539;San Francisco, CA 94107</p>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
<!-- prevent Gmail on iOS font size manipulation -->
<div style="display: none; white-space: nowrap; box-sizing: border-box; font: 15px/0 apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;"> &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; </div>
</body>
</html>`

View File

@@ -7,7 +7,7 @@ import (
"net/mail"
"regexp"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
)
// RemoveMessageHeaders scans a message for headers, if found them removes them.

View File

@@ -0,0 +1,44 @@
package tools
import (
"regexp"
"strings"
"github.com/axllent/mailpit/internal/tools/html2text"
)
// CreateSnippet returns a message snippet. It will use the HTML version (if it exists)
// otherwise the text version.
func CreateSnippet(text, html string) string {
text = strings.TrimSpace(text)
html = strings.TrimSpace(html)
limit := 200
spaceRe := regexp.MustCompile(`\s+`)
if text == "" && html == "" {
return ""
}
if html != "" {
data := html2text.Strip(html, false)
if len(data) <= limit {
return data
}
return data[0:limit] + "..."
}
if text != "" {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
text = strings.TrimSpace(spaceRe.ReplaceAllString(text, " "))
if len(text) <= limit {
return text
}
return text[0:limit] + "..."
}
return ""
}

View File

@@ -0,0 +1,71 @@
package tools
import (
"reflect"
"testing"
)
func TestArgsParser(t *testing.T) {
tests := map[string][]string{}
tests["this is a test"] = []string{"this", "is", "a", "test"}
tests["\"this is\" a test"] = []string{"this is", "a", "test"}
tests["!\"this is\" a test"] = []string{"!this is", "a", "test"}
tests["subject:this is a test"] = []string{"subject:this", "is", "a", "test"}
tests["subject:\"this is\" a test"] = []string{"subject:this is", "a", "test"}
tests["subject:\"this is\" \"a test\""] = []string{"subject:this is", "a test"}
tests["subject:\"this 'is\" \"a test\""] = []string{"subject:this 'is", "a test"}
tests["subject:\"this 'is a test"] = []string{"subject:this 'is a test"}
tests["\"this is a test\"=\"this is a test\""] = []string{"this is a test=this is a test"}
for search, expected := range tests {
res := ArgsParser(search)
if !reflect.DeepEqual(res, expected) {
t.Log("Args parser error:", res, "!=", expected)
t.Fail()
}
}
}
func TestCleanTag(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test -"
tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -"
tests["this_is-a test "] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
for search, expected := range tests {
res := CleanTag(search)
if res != expected {
t.Log("CleanTags error:", res, "!=", expected)
t.Fail()
}
}
}
func TestSnippets(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
tests["<h1>This is a test.</h1> "] = "This is a test."
tests["this_is-a test "] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a&^%%(*)@ test"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
// truncation to 200 chars + ...
tests["abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789"] = "abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmno..."
for str, expected := range tests {
res := CreateSnippet(str, str)
if res != expected {
t.Log("CreateSnippet error:", res, "!=", expected)
t.Fail()
}
}
}

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"runtime"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
)

844
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,17 +14,21 @@
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"modern-screenshot": "^4.4.30",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0"
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.18.10",
"esbuild": "^0.19.1",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}

View File

@@ -24,7 +24,7 @@ import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/reiver/go-telnet"
flag "github.com/spf13/pflag"
)

View File

@@ -11,13 +11,14 @@ import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/htmlcheck"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/google/uuid"
"github.com/gorilla/mux"
uuid "github.com/satori/go.uuid"
)
// GetMessages returns a paginated list of messages as JSON
@@ -130,8 +131,8 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Start = start
res.Messages = messages
res.Count = results // legacy - now undocumented in API specs
res.Total = stats.Total
res.Count = len(messages) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = results
res.Unread = stats.Unread
res.Tags = stats.Tags
@@ -141,6 +142,44 @@ func Search(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes)
}
// DeleteSearch will delete all messages matching a search
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/search messages DeleteSearch
//
// # Delete messages by search
//
// Delete all messages matching a search.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
if err := storage.DeleteSearch(search); err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message Message
@@ -157,7 +196,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Database ID
// description: Message database ID
// required: true
// type: string
//
@@ -198,7 +237,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Database ID
// description: Message database ID
// required: true
// type: string
// + name: PartID
@@ -323,11 +362,11 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages Delete
// swagger:route DELETE /api/v1/messages messages DeleteMessages
//
// # Delete messages
//
// If no IDs are provided then all messages are deleted.
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
@@ -337,13 +376,6 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to delete
// required: false
// type: DeleteRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
@@ -367,7 +399,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Add("Content-Type", "text/plain")
w.Header().Add("Content-Type", "application/plain")
_, _ = w.Write([]byte("ok"))
}
@@ -388,13 +420,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: false
// type: SetReadStatusRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
@@ -450,13 +475,42 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// GetTags (method: GET) will get all tags currently in use
func GetTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
tags := storage.GetAllTags()
data, err := json.Marshal(tags)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(data)
}
// SetTags (method: PUT) will set the tags for all provided IDs
func SetTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// To remove all tags from a message, pass an empty tags array.
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
@@ -466,13 +520,6 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: true
// type: SetTagsRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
@@ -508,11 +555,11 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
// If no IDs are provided then all messages are updated.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message Release
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server..
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
@@ -522,18 +569,6 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: to
// in: body
// description: Array of email addresses to release message to
// required: true
// type: ReleaseMessageRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
@@ -550,7 +585,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequest{}
data := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
@@ -618,7 +653,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
}
// generate unique ID
uid := uuid.NewV4().String() + "@mailpit"
uid := uuid.New().String() + "@mailpit"
// add unique ID
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
@@ -634,11 +669,11 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// HTMLCheck returns a summary of the HTML client support
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheckResponse
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
//
// # HTML check (beta)
//
// Returns the summary of HTML check.
// Returns the summary of the message HTML checker.
//
// NOTE: This feature is currently in beta and is documented for reference only.
// Please do not integrate with it (yet) as there may be changes.
@@ -648,13 +683,6 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: HTMLCheckResponse
// default: ErrorResponse
@@ -684,6 +712,49 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes)
}
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
//
// # Link check (beta)
//
// Returns the summary of the message Link checker.
//
// NOTE: This feature is currently in beta and is documented for reference only.
// Please do not integrate with it (yet) as there may be changes.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: LinkCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
f := r.URL.Query().Get("follow")
followRedirects := f == "true" || f == "1"
summary, err := linkcheck.RunTests(msg, followRedirects)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
@@ -721,7 +792,7 @@ func getStartLimit(req *http.Request) (start int, limit int) {
}
// GetOptions returns a blank response
func GetOptions(w http.ResponseWriter, r *http.Request) {
func GetOptions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(""))

View File

@@ -7,8 +7,8 @@ import (
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/updater"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/updater"
)
// Response includes the current and latest Mailpit version, database info, and memory usage

View File

@@ -1,8 +1,9 @@
package apiv1
import (
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/htmlcheck"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/storage"
)
// MessagesSummary is a summary of a list of messages
@@ -21,9 +22,6 @@ type MessagesSummary struct {
// Total number of messages matching current query
MessagesCount int `json:"messages_count"`
// // Number of results returned on current page
// Count int `json:"count"`
// Pagination offset
Start int `json:"start"`
@@ -31,7 +29,7 @@ type MessagesSummary struct {
Tags []string `json:"tags"`
// Messages summary
// in:body
// in: body
Messages []storage.MessageSummary `json:"messages"`
}
@@ -49,3 +47,6 @@ type Attachment = storage.Attachment
// HTMLCheckResponse summary
type HTMLCheckResponse = htmlcheck.Response
// LinkCheckResponse summary
type LinkCheckResponse = linkcheck.Response

View File

@@ -1,11 +1,15 @@
package apiv1
import "os"
// These structs are for the purpose of defining swagger HTTP responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
//
// in: body
Body appInformation
}
@@ -13,6 +17,8 @@ type infoResponse struct {
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
//
// in: body
Body webUIConfiguration
}
@@ -28,71 +34,141 @@ type messagesSummaryResponse struct {
// swagger:model MessageHeaders
type messageHeaders map[string][]string
// swagger:parameters DeleteMessages
type deleteMessagesParams struct {
// in: body
Body *deleteMessagesRequestBody
}
// Delete request
// swagger:model DeleteRequest
type deleteRequest struct {
// ids
// in:body
type deleteMessagesRequestBody struct {
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
}
// swagger:parameters SetReadStatus
type setReadStatusParams struct {
// in: body
Body *setReadStatusRequestBody
}
// Set read status request
// swagger:model SetReadStatusRequest
type setReadStatusRequest struct {
// swagger:model setReadStatusRequestBody
type setReadStatusRequestBody struct {
// Read status
//
// required: false
// default: false
// example: true
Read bool `json:"read"`
// ids
// in:body
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
}
// swagger:parameters SetTags
type setTagsParams struct {
// in: body
Body *setTagsRequestBody
}
// Set tags request
// swagger:model SetTagsRequest
type setTagsRequest struct {
// Tags
// in:body
// swagger:model setTagsRequestBody
type setTagsRequestBody struct {
// Array of tag names to set
//
// required: true
// example: ["Tag 1", "Tag 2"]
Tags []string `json:"tags"`
// IDs
// in:body
// Array of message database IDs
//
// required: true
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
}
// swagger:parameters ReleaseMessage
type releaseMessageParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
// in: body
Body *releaseMessageRequestBody
}
// Release request
// swagger:model ReleaseMessageRequest
type releaseMessageRequest struct {
// To
// in:body
// swagger:model releaseMessageRequestBody
type releaseMessageRequestBody struct {
// Array of email addresses to relay the message to
//
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string `json:"to"`
}
// swagger:parameters HTMLCheck
type htmlCheckParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
}
// swagger:parameters LinkCheck
type linkCheckParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
// Follow redirects
//
// in: query
// description: Follow redirects
// required: false
// default: false
Follow string `json:"follow"`
}
// Binary data response inherits the attachment's content type
// swagger:response BinaryResponse
type binaryResponse struct {
// in: body
Body string
File os.File
}
// Plain text response
// swagger:response TextResponse
type textResponse struct {
// in: body
Body string
}
type textResponse string
// HTML response
// swagger:response HTMLResponse
type htmlResponse string
// Error response
// swagger:response ErrorResponse
type errorResponse struct {
// The error message
// in: body
Body string
}
type errorResponse string
// Plain text "ok" response
// swagger:response OKResponse
type okResponse struct {
// Default response
// in: body
Body string
}
type okResponse string
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string

View File

@@ -10,8 +10,8 @@ import (
"net/http"
"strings"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"

View File

@@ -2,7 +2,7 @@ package handlers
import "net/http"
// Healthz is a liveness probe
// HealthzHandler is a liveness probe
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}

View File

@@ -0,0 +1,163 @@
package handlers
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
)
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /view/{ID}.html testing GetMessageHTML
//
// # Render message HTML part
//
// Renders just the message's HTML part which can be used for UI integration testing.
// Attached inline images are modified to link to the API provided they exist.
// Note that is the message does not contain a HTML part then an 404 error is returned.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - text/html
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID or latest
// required: true
// type: string
//
// Responses:
// 200: HTMLResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
messages, err := storage.List(0, 1)
if err != nil {
httpError(w, err.Error())
return
}
if len(messages) == 0 {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
id = messages[0].ID
}
msg, err := storage.GetMessage(id)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
if msg.HTML == "" {
w.WriteHeader(404)
fmt.Fprint(w, "This message does not contain a HTML part")
return
}
html := linkInlinedImages(msg)
w.Header().Add("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
// GetMessageText (method: GET) returns a message's text part
func GetMessageText(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /view/{ID}.txt testing GetMessageText
//
// # Render message text part
//
// Renders just the message's text part which can be used for UI integration testing.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID or latest
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
messages, err := storage.List(0, 1)
if err != nil {
httpError(w, err.Error())
return
}
if len(messages) == 0 {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
id = messages[0].ID
}
msg, err := storage.GetMessage(id)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(msg.Text))
}
// This will remap all attachment images with relative paths
func linkInlinedImages(msg *storage.Message) string {
html := msg.HTML
for _, a := range msg.Inline {
if a.ContentID != "" {
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
}
}
}
for _, a := range msg.Attachments {
if a.ContentID != "" {
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
}
}
}
return html
}

147
server/handlers/proxy.go Normal file
View File

@@ -0,0 +1,147 @@
// Package handlers contains a specific handlers
package handlers
import (
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
// ProxyHandler is used to proxy assets for printing
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
uri := strings.TrimSpace(r.URL.Query().Get("url"))
if uri == "" {
logger.Log().Warn("[proxy] URL missing")
httpError(w, "Error: URL missing")
return
}
if !linkRe.MatchString(uri) {
logger.Log().Warnf("[proxy] invalid URL %s", uri)
httpError(w, "Error: invalid URL")
return
}
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, err.Error())
return
}
// use requesting useragent
req.Header.Set("User-Agent", r.UserAgent())
resp, err := client.Do(req)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.Log().Warnf("[proxy] %s", err.Error())
httpError(w, err.Error())
return
}
// relay common headers
if resp.Header.Get("content-type") != "" {
w.Header().Set("content-type", resp.Header.Get("content-type"))
}
if resp.Header.Get("last-modified") != "" {
w.Header().Set("last-modified", resp.Header.Get("last-modified"))
}
if resp.Header.Get("content-disposition") != "" {
w.Header().Set("content-disposition", resp.Header.Get("content-disposition"))
}
if resp.Header.Get("cache-control") != "" {
w.Header().Set("cache-control", resp.Header.Get("cache-control"))
}
// replace url() values with proxy address, eg: fonts & images
if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") {
var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`)
body = re.ReplaceAllFunc(body, func(s []byte) []byte {
parts := re.FindStringSubmatch(string(s))
// don't resolve inline `data:..`
if strings.HasPrefix(parts[3], "data:") {
return []byte(parts[3])
}
address, err := absoluteURL(parts[3], uri)
if err != nil {
logger.Log().Error(err)
return []byte(parts[3])
}
return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")")
})
}
logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode)
// relay status code - WriteHeader must come after Header.Set()
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
func absoluteURL(link, baseURL string) (string, error) {
// scheme relative links, eg <script src="//example.com/script.js">
if len(link) > 1 && link[0:2] == "//" {
base, err := url.Parse(baseURL)
if err != nil {
return link, err
}
link = base.Scheme + ":" + link
}
u, err := url.Parse(link)
if err != nil {
return link, err
}
// remove hashes
u.Fragment = ""
base, err := url.Parse(baseURL)
if err != nil {
return link, err
}
result := base.ResolveReference(u)
// ensure link is HTTP(S)
if result.Scheme != "http" && result.Scheme != "https" {
return link, fmt.Errorf("Invalid URL: %s", result.String())
}
return result.String(), nil
}
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}

View File

@@ -12,12 +12,15 @@ import (
"os"
"strings"
"sync/atomic"
"text/template"
"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/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/mux"
)
@@ -42,28 +45,43 @@ func Listen() {
go websockets.MessageHub.Run()
r := defaultRoutes()
r := apiRoutes()
// kubernetes probes
r.HandleFunc("/livez", handlers.HealthzHandler)
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// virtual filesystem for others
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
redirect := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
// frontend testing
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
// web UI via virtual index.html
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
// put it all together
http.Handle("/", r)
if config.UIAuthFile != "" {
logger.Log().Info("[http] enabling web UI basic authentication")
if auth.UICredentials != nil {
logger.Log().Info("[http] enabling basic authentication")
}
// Mark the application here as ready
@@ -78,15 +96,17 @@ func Listen() {
}
}
func defaultRoutes() *mux.Router {
func apiRoutes() *mux.Router {
r := mux.NewRouter()
// API V1
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT")
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/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
@@ -95,11 +115,15 @@ func defaultRoutes() *mux.Router {
if !config.DisableHTMLCheck {
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
}
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
@@ -135,7 +159,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if config.UIAuthFile != "" {
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -143,7 +167,21 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return
}
if !config.UIAuth.Match(user, pass) {
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
@@ -174,7 +212,7 @@ func middlewareHandler(h http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if config.UIAuthFile != "" {
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -182,7 +220,7 @@ func middlewareHandler(h http.Handler) http.Handler {
return
}
if !config.UIAuth.Match(user, pass) {
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
@@ -207,6 +245,7 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
storage.BroadcastMailboxStats()
}
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
@@ -226,3 +265,55 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(f)
}
// Just returns the default HTML template
func index(w http.ResponseWriter, _ *http.Request) {
var h = `<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="{{ .Webroot }}favicon.svg">
<title>Mailpit</title>
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
</head>
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
<noscript>You require JavaScript to use this app.</noscript>
</div>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
</body>
</html>`
t, err := template.New("index").Parse(h)
if err != nil {
panic(err)
}
data := struct {
Webroot string
Version string
}{
Webroot: config.Webroot,
Version: config.Version,
}
buff := new(bytes.Buffer)
err = t.Execute(buff, data)
if err != nil {
panic(err)
}
buff.Bytes()
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(buff.Bytes())
}

View File

@@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -12,9 +12,9 @@ import (
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
@@ -25,11 +25,11 @@ var (
}
)
func Test_APIv1(t *testing.T) {
func TestAPIv1Messages(t *testing.T) {
setup()
defer storage.Close()
r := defaultRoutes()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
@@ -54,11 +54,11 @@ func Test_APIv1(t *testing.T) {
t.Errorf(err.Error())
}
// read first 10
// read first 10 messages
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for indx, msg := range m.Messages {
if indx == 10 {
for idx, msg := range m.Messages {
if idx == 10 {
break
}
@@ -66,12 +66,12 @@ func Test_APIv1(t *testing.T) {
t.Errorf(err.Error())
}
// test RAW
// get RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
}
// test headers
// het headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error())
}
@@ -79,11 +79,63 @@ func Test_APIv1(t *testing.T) {
// store for later
putIDS = append(putIDS, msg.ID)
}
// 10 should be marked as read
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// delete all
t.Log("Delete all messages")
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
}
func TestAPIv1ToggleReadStatus(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// check count of empty database
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// read first 10 IDs
t.Log("Get first 10 IDs")
putIDS := []string{}
for idx, msg := range m.Messages {
if idx == 10 {
break
}
// store for later
putIDS = append(putIDS, msg.ID)
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark first 10 as unread
t.Log("Mark first 10 as unread")
t.Log("Mark first 10 as read")
putData := putDataStruct
putData.Read = true
putData.IDs = putIDS
j, err := json.Marshal(putData)
if err != nil {
@@ -93,11 +145,11 @@ func Test_APIv1(t *testing.T) {
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// mark first 10 as read
t.Log("Mark first 10 as read")
putData.Read = true
t.Log("Mark first 10 as unread")
putData.Read = false
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
@@ -106,25 +158,7 @@ func Test_APIv1(t *testing.T) {
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// search
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
// delete first 10
t.Log("Delete first 10")
_, err = clientDelete(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 90)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark all as read
putData.Read = true
@@ -139,15 +173,34 @@ func Test_APIv1(t *testing.T) {
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
}
// delete all
t.Log("Delete all messages")
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
func TestAPIv1Search(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// search
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-from:from-1@example.com", 99)
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
}
func setup() {
@@ -253,7 +306,7 @@ func clientGet(url string) ([]byte, error) {
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}
@@ -278,7 +331,7 @@ func clientDelete(url, body string) ([]byte, error) {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}
@@ -303,7 +356,7 @@ func clientPut(url, body string) ([]byte, error) {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}

View File

@@ -8,7 +8,7 @@ import (
"net/smtp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/logger"
)
func allowedRecipients(to []string) []string {
@@ -63,22 +63,10 @@ func Send(from string, to []string, msg []byte) error {
}
}
var a smtp.Auth
auth := relayAuthFromConfig()
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
if a != nil {
if err = c.Auth(a); err != nil {
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
@@ -109,6 +97,25 @@ func Send(from string, to []string, msg []byte) error {
return c.Quit()
}
// Return the SMTP relay authentication based on config
func relayAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
return a
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {

View File

@@ -10,13 +10,20 @@ import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/google/uuid"
"github.com/mhale/smtpd"
uuid "github.com/satori/go.uuid"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if !config.SMTPStrictRFCHeaders {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
@@ -50,7 +57,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = uuid.NewV4().String() + "@mailpit"
messageID = uuid.New().String() + "@mailpit"
// add unique ID
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
@@ -123,7 +130,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
allow := config.SMTPAuthConfig.Match(string(username), string(password))
allow := auth.SMTPCredentials.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
@@ -143,14 +150,14 @@ func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtpd] enabling login auth via %s (insecure)", config.SMTPAuthFile)
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login auth (insecure)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling all auth (insecure)")
}
} else {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtpd] enabling login auth via %s (TLS)", config.SMTPAuthFile)
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login auth (TLS)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any auth (TLS)")
}
@@ -175,7 +182,7 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}
if config.SMTPAuthFile != "" {
if auth.SMTPCredentials != nil {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandler
srv.AuthRequired = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/styles.scss";
import "bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
import App from './App.vue'
import router from './router'
import { createApp } from 'vue'
createApp(App).mount('#app');
import './assets/styles.scss'
import 'bootstrap-icons/font/bootstrap-icons.scss'
import 'bootstrap'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -39,7 +39,7 @@
// @import "bootstrap/scss/popover";
// @import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
// @import "bootstrap/scss/offcanvas";
@import "bootstrap/scss/offcanvas";
// @import "bootstrap/scss/popover";
@import "bootstrap/scss/progress";

View File

@@ -1,9 +1,21 @@
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans",
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-family-sans-serif:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
"Noto Sans",
"Liberation Sans",
Arial,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol";
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;
$body-color-dark: #e7eaed;
$offcanvas-border-width: 0;

View File

@@ -1,369 +1,400 @@
@import "./bootstrap";
[v-cloak] {
display: none !important;
display: none !important;
}
.navbar {
z-index: 99;
z-index: 99;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
}
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
img {
width: 35px;
}
}
}
}
.navbar-brand {
span {
opacity: 0.8;
transition: all 0.5s;
}
span {
opacity: 0.8;
transition: all 0.5s;
}
&:hover {
span {
opacity: 1;
}
}
&:hover {
span {
opacity: 1;
}
}
}
.nav-tabs .nav-link {
@include media-breakpoint-down(sm) {
// font-size: 14px;
padding-left: 10px;
padding-right: 10px;
}
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
}
:not(.text-view) > a {
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
:not(.text-view) > a:not(.no-icon) {
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
}
#loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
.loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
}
// dark mode adjustments
@include color-mode(dark) {
#loading {
background: rgba(0, 0, 0, 0.4);
}
.loader {
background: rgba(0, 0, 0, 0.4);
}
.token.tag,
.token.property {
color: #ee6969;
}
.token.tag,
.token.property {
color: #ee6969;
}
}
.about-mailpit {
@include media-breakpoint-down(md) {
width: var(--bs-offcanvas-width);
margin-left: -1rem !important;
}
}
.message {
&.read {
color: $text-muted;
.subject {
color: $text-muted;
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
opacity: 0.7;
font-weight: normal;
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family:
Courier New,
Courier,
System,
fixed-width;
font-size: 0.85em;
white-space: pre;
font-family: "Courier New", Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-html-source pre[class*="language-"] code {
white-space: pre-wrap;
white-space: pre-wrap;
}
#nav-plain-text .text-view {
white-space: pre-wrap;
white-space: pre-wrap;
}
.messageHeaders {
margin: 15px 0 0;
margin: 15px 0 0;
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
td {
vertical-align: top;
}
td {
vertical-align: top;
}
}
#nav-html {
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
}
#preview-html {
min-height: 300px;
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
margin: auto;
transition: width 0.5s;
position: relative;
margin: auto;
transition: width 0.5s;
position: relative;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
iframe {
height: 100% !important;
background: #fff;
}
}
iframe {
height: 100% !important;
background: #fff;
}
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
}
.messageHeaders {
th {
vertical-align: top;
}
}
.list-group-item.message:first-child {
border-top: 0;
border-top: 0;
}
body.blur {
.privacy {
filter: blur(3px);
}
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
.form-select.tag-selector {
display: none;
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
div {
cursor: text; // html5-tags
}
}
// bootstrap5-tags
.tags-badge {
display: flex;
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
.dropdown-menu {
left: 0;
right: 0;
}
}
}
#ReleaseModal {
.form-control.dropdown {
div {
@extend .form-control;
}
}
.form-control.dropdown {
div {
@extend .form-control;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
@@ -371,10 +402,10 @@ pre[class*="language-"] {
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
color: #5f6364;
}
.token.boolean,
.token.constant,
@@ -384,7 +415,7 @@ pre[class*="language-"] {
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
@@ -393,70 +424,70 @@ pre[class*="language-"] {
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
font-weight: 400;
}
.token.bold {
font-weight: 700;
font-weight: 700;
}
.token.italic {
font-style: italic;
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.namespace {
opacity: 0.7;
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
margin-top: 0;
}

View File

@@ -0,0 +1,239 @@
<script>
import AjaxLoader from './AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
components: {
AjaxLoader
},
props: {
modals: {
type: Boolean,
default: false,
}
},
data() {
return {
mailbox,
theme: 'auto',
icon: 'circle-half',
icons: {
'auto': 'circle-half',
'light': 'sun-fill',
'dark': 'moon-stars-fill'
},
}
},
mounted() {
this.setTheme(this.getPreferredTheme())
},
methods: {
loadInfo: function () {
let self = this
self.get(self.resolve('/api/v1/info'), false, function (response) {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
})
},
getStoredTheme: function () {
let theme = localStorage.getItem('theme')
if (!theme) {
theme = 'auto'
}
return theme
},
setStoredTheme: function (theme) {
localStorage.setItem('theme', theme)
this.setTheme(theme)
},
getPreferredTheme: function () {
const storedTheme = this.getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
setTheme: function (theme) {
this.icon = this.icons[theme]
this.theme = theme
if (
theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
},
requestNotifications: function () {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notification")
}
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
mailbox.notificationsEnabled = true
}
})
}
},
}
}
</script>
<template>
<template v-if="!modals">
<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
<i class="bi bi-info-circle-fill me-1"></i>
About
</button>
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
<i :class="'bi bi-' + icon + ' my-1'"></i>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
<i class="bi bi-sun-fill me-2 opacity-50"></i>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
<i class="bi bi-moon-stars-fill me-2 opacity-50"></i>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
<i class="bi bi-circle-half me-2 opacity-50"></i>
Auto
</button>
</li>
</ul>
</div>
<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">
<i class="bi bi-bell"></i>
</button>
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" v-if="mailbox.appInfo">
<h5 class="modal-title" id="AppInfoModalLabel">
Mailpit
<code>({{ mailbox.appInfo.Version }})</code>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<a class="btn btn-warning d-block mb-3"
v-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
<div class="row g-3">
<div class="col-12">
<RouterLink to="/api/v1/" class="btn btn-primary w-100" target="_blank">
<i class="bi bi-braces"></i>
OpenAPI / Swagger API documentation
</RouterLink>
</div>
<div class="col-sm-6">
<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://github.com/axllent/mailpit/wiki"
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) }} </h5>
</div>
</div>
</div>
<div class="col-6">
<div class="card border-secondary text-center">
<div class="card-header">RAM usage</div>
<div class="card-body text-secondary">
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.Memory) }} </h5>
</div>
</div>
</div>
</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 class="modal fade" id="EnableNotificationsModal" 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>
<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.
</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" data-bs-dismiss="modal"
v-on:click="requestNotifications">Enable notifications</button>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

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

View File

@@ -0,0 +1,177 @@
<script>
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import moment from 'moment'
export default {
mixins: [
CommonMixins
],
props: {
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
},
data() {
return {
mailbox,
}
},
mounted() {
moment.updateLocale('en', {
relativeTime: {
future: "in %s",
past: "%s ago",
s: 'seconds',
ss: '%d secs',
m: "a minute",
mm: "%d mins",
h: "an hour",
hh: "%d hours",
d: "a day",
dd: "%d days",
w: "a week",
ww: "%d weeks",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
}
})
},
methods: {
getRelativeCreated: function (message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString()
},
getPrimaryEmailTo: function (message) {
for (let i in message.To) {
return message.To[i].Address
}
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
e.preventDefault()
if (this.isSelected(id)) {
mailbox.selected = mailbox.selected.filter(function (ele) {
return ele != id
})
} else {
mailbox.selected.push(id)
}
},
selectRange: function (e, id) {
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
}
if (lastSelected === false) {
mailbox.selected.push(id)
return
}
for (let d of mailbox.messages) {
if (selecting) {
if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID)
}
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
break
}
} else if (d.ID == id || d.ID == lastSelected) {
if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID)
}
selecting = true
}
}
},
}
}
</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" :id="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' : ''"
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on: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>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>
</div>
</div>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
<div class="subject text-truncate">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
</div>
<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="'/search?q=' + tagEncodeURI(t)"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ 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>
{{ getFileSize(message.Size) }}
</div>
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
</RouterLink>
</div>
</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>
<template v-else>No messages in your mailbox</template>
</p>
</template>
</template>

View File

@@ -0,0 +1,139 @@
<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'
export default {
mixins: [CommonMixins],
components: {
NavSelected,
AjaxLoader,
},
props: {
modals: {
type: Boolean,
default: false,
}
},
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
pagination.start = 0
self.loadMessages()
})
}
}
}
</script>
<template>
<template v-if="!modals">
<div class="list-group my-2">
<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>
<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">
{{ formatNumber(mailbox.unread) }}
</span>
</button>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
<NavSelected @loadMessages="loadMessages" />
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all 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.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markAllRead">Confirm</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">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.count) }}
message<span v-if="mailbox.count > 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>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -0,0 +1,103 @@
<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'
export default {
mixins: [CommonMixins],
components: {
NavSelected,
AjaxLoader,
},
props: {
modals: {
type: Boolean,
default: false,
}
},
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
loadMessages: function () {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
deleteAllMessages: function () {
let s = this.getSearch()
if (!s) {
return
}
let self = this
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, function (response) {
self.$router.push('/')
})
}
}
}
</script>
<template>
<template v-if="!modals">
<div class="list-group my-2">
<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">
{{ formatNumber(mailbox.unread) }}
</span>
</RouterLink>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
<NavSelected @loadMessages="loadMessages" />
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="DeleteAllModal" 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>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }}
message<span v-if="mailbox.count > 1">s</span> matching
<code>{{ getSearch() }}</code>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
v-on:click="deleteAllMessages">Delete</button>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -0,0 +1,120 @@
<script>
import AjaxLoader from './AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
components: {
AjaxLoader,
},
emits: ['loadMessages'],
data() {
return {
mailbox,
}
},
methods: {
loadMessages: function () {
this.$emit('loadMessages')
},
// mark selected messages as read
markSelectedRead: function () {
let self = this
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
isSelected: function (id) {
return mailbox.selected.indexOf(id) != -1
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
// universal handler to delete current or selected messages
deleteMessages: function () {
let ids = []
let self = this
ids = JSON.parse(JSON.stringify(mailbox.selected))
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
// test if any selected emails are unread
selectedHasUnread: function () {
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: function () {
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
},
}
}
</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>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -0,0 +1,55 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
}
},
methods: {
inSearch: function (tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
return query.match(re)
}
}
}
</script>
<template>
<template v-if="mailbox.tags && mailbox.tags.length">
<div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
<template v-if="mailbox.showTagColors">Hide</template>
<template v-else>Show</template>
tag colors
</button>
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
: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>
{{ tag }}
</RouterLink>
</div>
</template>
</template>

View File

@@ -0,0 +1,176 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { Toast } from 'bootstrap'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
data() {
return {
pagination,
mailbox,
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
pauseNotifications: false, // prevent spamming
}
},
mounted() {
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.connect()
mailbox.notificationsSupported = window.isSecureContext
&& ("Notification" in window && Notification.permission !== "denied")
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
},
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
let response = JSON.parse(e.data)
if (!response) {
return
}
// new messages
if (response.Type == "new" && response.Data) {
if (!mailbox.searching) {
if (pagination.start < 1) {
// push results directly into first page
mailbox.messages.unshift(response.Data)
if (mailbox.messages.length > pagination.limit) {
mailbox.messages.pop()
}
} else {
// update pagination offset
pagination.start++
}
}
for (let i in response.Data.Tags) {
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
mailbox.tags.push(response.Data.Tags[i])
mailbox.tags.sort()
}
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.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)
} else if (response.Type == "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total
mailbox.unread = response.Data.Unread
}
}
ws.onopen = function () {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
setTimeout(function () {
self.connect() // reconnect
}, 1000)
}
ws.onerror = function (err) {
ws.close()
}
},
browserNotify: function (title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
}
new Notification(title, options)
}
},
setMessageToast: function (m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
}
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}
},
},
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<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

@@ -0,0 +1,92 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
props: {
total: Number,
},
emits: ['loadMessages'],
data() {
return {
pagination,
mailbox,
}
},
computed: {
canPrev: function () {
return pagination.start > 0
},
canNext: function () {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages: function () {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
}
return t
},
},
methods: {
changeLimit: function () {
pagination.start = 0
this.$emit('loadMessages')
},
viewNext: function () {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.$emit('loadMessages')
},
viewPrev: function () {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
}
pagination.start = s
this.$emit('loadMessages')
},
}
}
</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 value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</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

@@ -0,0 +1,64 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
data() {
return {
search: '',
pagination
}
},
mounted() {
this.searchFromURL()
},
watch: {
$route() {
this.searchFromURL()
}
},
methods: {
searchFromURL: function () {
const urlParams = new URLSearchParams(window.location.search)
this.search = urlParams.get('q') ? urlParams.get('q') : ''
},
doSearch: function (e) {
pagination.start = 0
if (this.search == '') {
this.$router.push('/')
} else {
this.$router.push('/search?q=' + encodeURIComponent(this.search))
}
e.preventDefault()
},
resetSearch: function () {
this.search = ''
this.$router.push('/')
}
}
}
</script>
<template>
<form v-on: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>
</div>
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
</div>
</form>
</template>

View File

@@ -1,6 +1,6 @@
<script>
import commonMixins from '../mixins.js';
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
@@ -14,9 +14,9 @@ export default {
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="'api/v1/message/' + message.ID + '/part/' + part.PartID"
<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">
<img v-if="isImage(part)" :src="'api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb'"
<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=="

View File

@@ -1,7 +1,7 @@
<script>
import axios from 'axios'
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
import commonMixins from '../mixins.js'
import axios from 'axios'
import commonMixins from '../../mixins/CommonMixins'
import { Tooltip } from 'bootstrap'
export default {
@@ -225,7 +225,7 @@ export default {
let self = this
// ignore any error, do not show loader
axios.get('api/v1/message/' + self.message.ID + '/html-check', null)
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
@@ -640,7 +640,7 @@ export default {
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="HTMLCheckOptionsLabel">About HTML check</h1>
<h1 class="modal-title fs-5" id="HTMLCheckOptionsLabel">HTML check options</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">

View File

@@ -1,6 +1,6 @@
<script>
import commonMixins from '../mixins.js'
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
@@ -17,7 +17,7 @@ export default {
mounted() {
let self = this;
let uri = 'api/v1/message/' + self.message.ID + '/headers'
let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers')
self.get(uri, false, function (response) {
self.headers = response.data
});
@@ -28,10 +28,10 @@ export default {
<template>
<div v-if="headers" class="small">
<div v-for="vals, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<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 vals" class="mb-2 text-break">{{ x }}</div>
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,398 @@
<script>
import axios from 'axios'
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
message: Object,
},
emits: ["setLinkErrors"],
mixins: [commonMixins],
data() {
return {
error: false,
autoScan: false,
followRedirects: false,
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: function () {
let results = {}
if (!this.check) {
return results
}
// group by status
this.check.Links.forEach(function (r) {
if (!results[r.StatusCode]) {
let css = ""
if (r.StatusCode >= 400 || r.StatusCode === 0) {
css = "text-danger"
} else if (r.StatusCode >= 300) {
css = "text-info"
}
if (r.StatusCode === 0) {
r.Status = 'Cannot connect to server'
}
results[r.StatusCode] = {
StatusCode: r.StatusCode,
Status: r.Status,
Class: css,
URLS: []
}
}
results[r.StatusCode].URLS.push(r.URL)
})
let newArr = []
for (const i in results) {
newArr.push(results[i])
}
// sort statuses
let sorted = newArr.sort((a, b) => {
if (a.StatusCode === 0) {
return false
}
return a.StatusCode < b.StatusCode
})
return sorted
}
},
methods: {
doCheck: function () {
this.check = false
this.loading = true
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
if (this.followRedirects) {
uri += '?follow=true'
}
let self = this
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {
self.check = result.data
self.error = false
self.$emit('setLinkErrors', result.data.Errors)
})
.catch(function (error) {
// handle error
if (error.response && error.response.data) {
// 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) {
self.error = error.response.data.Error
} else {
self.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
self.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
}
})
.then(function (result) {
// always run
self.loading = false
})
},
}
}
</script>
<template>
<div class="pe-3">
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<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
</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">
<i class="bi bi-info-circle-fill"></i>
Help
</button>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#LinkCheckOptions">
<i class="bi bi-gear-fill"></i>
Settings
</button>
</div>
</div>
</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>
<p class="text-center my-5">
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading">
<template v-if="loading">
Checking links
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
<template v-else>
<i class="bi bi-check-square me-2"></i>
Check message links
</template>
</button>
</p>
</div>
<div v-else v-for="s, k in groupedStatuses">
<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>
</div>
<ul class="list-group list-group-flush">
<li v-for="u in s.URLS" class="list-group-item">
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
</li>
</ul>
</div>
</div>
<template v-if="error">
<p>Link check failed to load:</p>
<div class="alert alert-warning">
{{ error }}
</div>
</template>
</div>
<div class="modal fade" id="LinkCheckOptions" 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>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<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">
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
<template v-if="followRedirects">Following HTTP redirects</template>
<template v-else>Not following HTTP redirects</template>
</label>
</div>
<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">
<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>
</label>
<div class="form-text">
Note: Enabling auto checking will scan every link & image every time a message is opened.
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>
</div>
</div>
</div>
</div>
<div class="modal fade" id="AboutLinkCheckResults" 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>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="LinkCheckAboutAccordion">
<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">
What is Link check?
</button>
</h2>
<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 time, to
test whether the link/image/stylesheet exists.
</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="#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 class="accordion-body">
<p>
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.
</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">
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 class="accordion-body">
<p>This may be due to various reasons, for instance:</p>
<ul>
<li>The Mailpit server cannot resolve (DNS) the hostname of the URL.</li>
<li>Mailpit is not allowed to access the URL.</li>
<li>
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>
</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">
What are the risks of running Link check automatically?
</button>
</h2>
<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 messages
may have undesired consequences:
</p>
<ul>
<li>If the message contains tracking links this may reveal your identity.</li>
<li>
If the message contains unsubscribe links, Link check could unintentionally
unsubscribe you.
</li>
<li>
To speed up the checking process, Link check will attempt 5 URLs at a time. This
could lead to temporary heady load on the remote server.
</li>
</ul>
<p>
Unless you know what messages you receive, it is advised to only run the Link check
manually.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,38 +1,39 @@
<script>
import commonMixins from '../mixins.js'
import Prism from "prismjs"
import Tags from "bootstrap5-tags"
import Attachments from './Attachments.vue'
import HTMLCheck from './HTMLCheck.vue'
import Headers from './Headers.vue'
import HTMLCheck from './MessageHTMLCheck.vue'
import LinkCheck from './LinkCheck.vue'
import Prism from 'prismjs'
import Tags from 'bootstrap5-tags'
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
export default {
props: {
message: Object,
existingTags: Array,
uiConfig: Object
},
components: {
Attachments,
Headers,
HTMLCheck,
LinkCheck,
},
mixins: [commonMixins],
data() {
return {
mailbox,
srcURI: false,
iframes: [], // for resizing
showTags: false, // to force re-rendering of component
canSaveTags: false, // prevent auto-saving tags on render
messageTags: [],
allTags: [],
loadHeaders: false,
htmlScore: false,
htmlScoreColor: false,
linkCheckErrors: false,
showMobileButtons: false,
scaleHTMLPreview: 'display',
// keys names match bootstrap icon names
@@ -45,39 +46,15 @@ export default {
},
watch: {
// handle changes to the URL messageID
message: {
handler() {
let self = this
self.showTags = false
self.canSaveTags = false
self.messageTags = self.message.Tags
self.allTags = self.existingTags
self.loadHeaders = false
self.scaleHTMLPreview = 'display' // default view
// delay to select first tab and add HTML highlighting (prev/next)
self.$nextTick(function () {
self.renderUI()
self.showTags = true
self.$nextTick(function () {
Tags.init("select[multiple]")
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
})
})
},
// force eager callback execution
immediate: true
},
messageTags() {
// save changes to tags
if (this.canSaveTags) {
// save changes to tags
this.saveTags()
}
},
scaleHTMLPreview() {
if (this.scaleHTMLPreview == 'display') {
scaleHTMLPreview(v) {
if (v == 'display') {
let self = this
window.setTimeout(function () {
self.resizeIFrames()
@@ -88,9 +65,10 @@ export default {
mounted() {
let self = this
self.showTags = false
self.canSaveTags = false
self.allTags = self.existingTags
self.messageTags = self.message.Tags
self.renderUI()
window.addEventListener("resize", self.resizeIFrames)
let headersTab = document.getElementById('nav-headers-tab')
@@ -100,14 +78,16 @@ export default {
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw'
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
self.resizeIFrames()
})
self.showTags = true
self.$nextTick(function () {
// manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
mailbox.tags = response.data
self.$nextTick(function () {
Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
@@ -115,19 +95,16 @@ export default {
})
},
unmounted: function () {
window.removeEventListener("resize", this.resizeIFrames)
},
methods: {
isHTMLTabSelected: function () {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () {
let self = this
// click the first non-disabled tab
// 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
@@ -208,8 +185,8 @@ export default {
tags: this.messageTags
}
self.put('api/v1/tags', data, function (response) {
self.scrollInPlace = true
self.put(self.resolve('/api/v1/tags'), data, function (response) {
window.scrollInPlace = true
self.$emit('loadMessages')
})
},
@@ -219,7 +196,7 @@ export default {
let html = s
// full links with http(s)
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+.~#?,&\/\/=;]+)\b/gim
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
// plain www links without https?:// prefix
@@ -255,7 +232,9 @@ export default {
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address" class="small">
&lt;{{ message.From.Address }}&gt;
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }}
</a>&gt;
</span>
</span>
<span v-else>
@@ -268,7 +247,12 @@ export default {
<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 class="text-nowrap">{{ t.Name + " &lt;" + t.Address + "&gt;" }}</span>
<span>
{{ t.Name }}
&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>
@@ -278,7 +262,11 @@ export default {
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
{{ t.Name }}
&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">
@@ -286,21 +274,32 @@ export default {
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }}
{{ t.Name }}
&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">
<td class="privacy text-body-secondary text-break">
<span v-for="(t, i) in message.ReplyTo">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
{{ t.Name }}
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary">
{{ t.Address }}
</a>&gt;
</span>
</td>
</tr>
<tr v-if="message.ReturnPath && message.ReturnPath != message.From.Address" class="small">
<th class="text-nowrap">Return-Path</th>
<td class="privacy text-body-secondary">&lt;{{ message.ReturnPath }}&gt;</td>
<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>
@@ -314,7 +313,7 @@ export default {
<td>{{ messageDate(message.Date) }}</td>
</tr>
<tr class="small" v-if="showTags">
<tr class="small">
<th>Tags</th>
<td>
<select class="form-select small tag-selector" v-model="messageTags" multiple
@@ -324,9 +323,9 @@ export default {
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allTags" :value="t">{{ t }}</option>
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Please select a valid tag.</div>
<div class="invalid-feedback">Invalid tag name</div>
</td>
</tr>
</tbody>
@@ -344,15 +343,31 @@ export default {
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<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" v-if="message.HTML" ref="navhtml"
v-on:click="resizeIFrames()">
HTML
</button>
<button class="nav-link" 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" v-if="message.HTML">
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
</button>
<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' : ''">
@@ -366,15 +381,51 @@ export default {
role="tab" aria-controls="nav-raw" aria-selected="false">
Raw
</button>
<button class="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="!uiConfig.DisableHTMLCheck && message.HTML != ''" @click="showMobileButtons = false">
<span class="d-none d-sm-inline">HTML</span> Check
<span class="position-absolute top-10 start-100 translate-middle badge rounded-pill p-1"
:class="htmlScoreColor" v-if="htmlScore !== false">
<div 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">
<li>
<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" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
</li>
<li>
<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
<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>
</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.uiConfig.DisableHTMLCheck && 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">
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>
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
<template v-for="vals, key in responsiveSizes">
@@ -392,17 +443,12 @@ export default {
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="sanitizeHTML(message.HTML)"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%;">
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-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
<HTMLCheck v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
@@ -420,6 +466,15 @@ export default {
<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.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,153 @@
<script>
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'],
data() {
return {
addresses: [],
deleteAfterRelease: false,
mailbox,
allAddresses: [],
}
},
mixins: [commonMixins],
mounted() {
let a = []
for (let 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 (let 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.addresses = this.allAddresses
},
methods: {
// triggered manually after modal is shown
initTags: function () {
Tags.init("select[multiple]")
},
releaseMessage: function () {
let self = this
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false
}
let data = {
to: self.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
self.modal("ReleaseModal").hide()
if (self.deleteAfterRelease) {
self.$emit('delete')
}
})
}, 100)
}
}
}
</script>
<template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From.Address">
</div>
</div>
<div class="row">
<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">
</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" 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="|,|">
<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>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<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">
<label class="form-check-label" for="DeleteAfterRelease">
Delete the message after release
</label>
</div>
</div>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ mailbox.uiConfig.MessageRelay.RecipientAllowlist }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">{{ mailbox.uiConfig.MessageRelay.ReturnPath
}}</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
</div>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -0,0 +1,147 @@
<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: function () {
this.loading = 1
let self = this
// 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, function (match, p1, p2, p3) {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(self.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(self.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(self.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(self.decodeEntities(src)))
}
}
// set html with manipulated document content
this.html = new XMLSerializer().serializeToString(doc)
},
// HTML decode function
decodeEntities: function (s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
e.textContent = ''
return str
},
doScreenshot: function () {
let self = this
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
}
let 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 = self.message.ID + '.png'
link.href = dataUrl
link.click()
self.loading = 0
self.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,76 +1,88 @@
import axios from 'axios'
import { Modal } from 'bootstrap'
import moment from 'moment'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
// FakeModal is used to return a fake Bootstrap modal
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
function FakeModal() { }
FakeModal.prototype.hide = function () { }
FakeModal.prototype.show = function () { }
class BootstrapElement {
constructor() { }
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 */
const commonMixins = {
export default {
data() {
return {
loading: 0,
tagColorCache: {},
showTagColors: true
}
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
},
methods: {
resolve: function (u) {
return this.$router.resolve(u).href
},
searchURI: function (s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
},
getFileSize: function (bytes) {
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];
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]
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
return new Intl.NumberFormat().format(nr)
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
return moment(d).format('ddd, D MMM YYYY, h:mm a')
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// 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);
} else {
alert(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
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);
tagEncodeURI: function (tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
}
return encodeURIComponent(`tag:${tag}`)
},
getSearch: function () {
if (!window.location.search) {
return false
}
const urlParams = new URLSearchParams(window.location.search)
const q = urlParams.get('q').trim()
if (q == '') {
return false
}
return q
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
let e = document.getElementById(id)
if (e) {
return Modal.getOrCreateInstance(e);
return Modal.getOrCreateInstance(e)
}
// in case there are open/close actions
return new FakeModal();
return new BootstrapElement()
},
// close mobile navigation
hideNav: function () {
let e = document.getElementById('offcanvas')
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
}
},
/**
@@ -79,19 +91,26 @@ const commonMixins = {
* @params string url
* @params array array parameters Object/array
* @params function callback function
* @params function error callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
get: function (url, values, callback, errorCallback) {
let self = this
self.loading++
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.catch(function (err) {
if (typeof errorCallback == 'function') {
return errorCallback(err)
}
self.handleError(err)
})
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@@ -102,17 +121,17 @@ const commonMixins = {
* @params function callback function
*/
post: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@@ -123,17 +142,17 @@ const commonMixins = {
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@@ -144,73 +163,93 @@ const commonMixins = {
* @params function callback function
*/
put: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// 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)
} else {
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.')
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message)
}
},
allAttachments: function (message) {
let a = [];
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
a.push(message.Attachments[i])
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
a.push(message.OtherParts[i])
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
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: function (a) {
let ext = a.FileName.split('.').pop().toLowerCase();
let 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';
return 'bi-file-pdf-fill'
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill';
return 'bi-file-word-fill'
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill';
return 'bi-file-spreadsheet-fill'
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill';
return 'bi-file-slides-fill'
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill';
return 'bi-file-zip-fill'
}
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';
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.
@@ -223,18 +262,5 @@ const commonMixins = {
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
}
}
export default commonMixins;

View File

@@ -0,0 +1,90 @@
import CommonMixins from './CommonMixins.js'
import { mailbox } from '../stores/mailbox.js'
import { pagination } from '../stores/pagination.js'
export default {
mixins: [CommonMixins],
data() {
return {
apiURI: false,
pagination,
mailbox,
}
},
watch: {
'mailbox.refresh': function (v) {
if (v) {
// trigger a refresh
this.loadMessages()
}
mailbox.refresh = false
}
},
methods: {
reloadMailbox: function () {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
if (!this.apiURI) {
alert('apiURL not set!')
return
}
let self = this
let params = {}
mailbox.selected = []
params['limit'] = pagination.limit
if (pagination.start > 0) {
params['start'] = pagination.start
}
self.get(this.apiURI, params, function (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
// ensure the pagination remains consistent
pagination.start = response.data.start
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return self.loadMessages()
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
let m = document.getElementById(mailbox.lastMessage)
if (m) {
m.focus()
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
} else {
let mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
}
mailbox.lastMessage = false
}, 50)
} else if (!window.scrollInPlace) {
let mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
}
window.scrollInPlace = false
})
},
}
}

View File

@@ -0,0 +1,37 @@
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 = '/'
if (d) {
webroot = d.dataset.webroot
}
// paths are relative to webroot
const router = createRouter({
history: createWebHistory(webroot),
routes: [
{
path: '/',
component: MailboxView
},
{
path: '/search',
component: SearchView
},
{
path: '/view/:id',
component: MessageView
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFoundView
}
]
})
export default router

View File

@@ -0,0 +1,59 @@
// State Management
import { reactive, watch } from 'vue'
import Tinycon from 'tinycon'
Tinycon.setOptions({
height: 11,
background: '#dd0000',
fallback: false,
font: '9px arial',
})
// 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
showTagColors: false, // show tag colors?
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
notificationsSupported: false,
notificationsEnabled: false,
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
})
watch(
() => mailbox.unread,
(v) => {
if (v == 0) {
Tinycon.reset()
} else {
Tinycon.setBubble(v)
}
}
)
watch(
() => mailbox.count,
(v) => {
mailbox.selected = []
}
)
watch(
() => mailbox.showTagColors,
(v) => {
if (v) {
localStorage.setItem('showTagsColors', '1')
} else {
localStorage.removeItem('showTagsColors')
}
}
)

View File

@@ -0,0 +1,8 @@
import { reactive } from 'vue'
export const pagination = reactive({
start: 0, // pagination offset
limit: 50, // per page
total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed
})

View File

@@ -1,116 +0,0 @@
<script>
import Tags from "bootstrap5-tags"
import commonMixins from '../mixins.js'
export default {
props: {
message: Object,
uiConfig: Object,
releaseAddresses: Array
},
data() {
return {
addresses: []
}
},
mixins: [commonMixins],
mounted() {
this.addresses = JSON.parse(JSON.stringify(this.releaseAddresses))
this.$nextTick(function () {
Tags.init("select[multiple]")
})
},
methods: {
releaseMessage: function () {
let self = this
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false
}
let data = {
to: self.addresses
}
self.post('api/v1/message/' + self.message.ID + '/release', data, function (response) {
self.modal("ReleaseModal").hide()
})
}, 100)
}
}
}
</script>
<template>
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From.Address">
</div>
</div>
<div class="row">
<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">
</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" 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="|,|">
<option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in releaseAddresses" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div class="form-text text-center" v-if="uiConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ uiConfig.MessageRelay.RecipientAllowlist }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="uiConfig.MessageRelay.ReturnPath != ''">{{ uiConfig.MessageRelay.ReturnPath }}</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script>
import commonMixins from '../mixins.js'
export default {
props: {
message: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="card mt-4">
<div class="card-body text-body-secondary small">
<p class="card-text">
<b>Message date:</b><br>
<small>{{ messageDate(message.Date) }}</small>
</p>
<p class="card-text">
<b>Size:</b> {{ getFileSize(message.Size) }}
</p>
<p class="card-text" v-if="allAttachments(message).length">
<b>Attachments:</b> {{ allAttachments(message).length }}
</p>
</div>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script>
import { Toast } from 'bootstrap'
export default {
props: {
message: Object
},
mounted() {
let self = this
let el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.$emit("clearMessageToast")
})
let b = Toast.getOrCreateInstance(el)
b.show()
}
}
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto"><a :href="'#' + message.ID">New message</a></strong>
<small class="text-body-secondary">now</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<a :href="'#' + message.ID" class="d-block text-truncate text-body-secondary">
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
<template v-else>[ no subject ]</template>
</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,123 +0,0 @@
<script>
export default {
data() {
return {
theme: 'auto',
icon: '#circle-half',
icons: {
'auto': '#circle-half',
'light': '#sun-fill',
'dark': '#moon-stars-fill'
}
}
},
mounted() {
this.setTheme(this.getPreferredTheme())
},
methods: {
getStoredTheme: function () {
let theme = localStorage.getItem('theme')
if (!theme) {
theme = 'auto'
}
return theme
},
setStoredTheme: function (theme) {
localStorage.setItem('theme', theme)
this.setTheme(theme)
},
getPreferredTheme: function () {
const storedTheme = this.getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
},
setTheme: function (theme) {
this.icon = this.icons[theme]
this.theme = theme
if (
theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
}
}
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="bootstrap" viewBox="0 0 512 408" fill="currentcolor">
<path
d="M106.342 0c-29.214 0-50.827 25.58-49.86 53.32.927 26.647-.278 61.165-8.966 89.31C38.802 170.862 24.07 188.707 0 191v26c24.069 2.293 38.802 20.138 47.516 48.37 8.688 28.145 9.893 62.663 8.965 89.311C55.515 382.42 77.128 408 106.342 408h299.353c29.214 0 50.827-25.58 49.861-53.319-.928-26.648.277-61.166 8.964-89.311 8.715-28.232 23.411-46.077 47.48-48.37v-26c-24.069-2.293-38.765-20.138-47.48-48.37-8.687-28.145-9.892-62.663-8.964-89.31C456.522 25.58 434.909 0 405.695 0H106.342zm236.559 251.102c0 38.197-28.501 61.355-75.798 61.355h-87.202a2 2 0 01-2-2v-213a2 2 0 012-2h86.74c39.439 0 65.322 21.354 65.322 54.138 0 23.008-17.409 43.61-39.594 47.219v1.203c30.196 3.309 50.532 24.212 50.532 53.085zm-84.58-128.125h-45.91v64.814h38.669c29.888 0 46.373-12.03 46.373-33.535 0-20.151-14.174-31.279-39.132-31.279zm-45.91 90.53v71.431h47.605c31.12 0 47.605-12.482 47.605-35.941 0-23.46-16.947-35.49-49.608-35.49h-45.602z" />
</symbol>
<symbol id="check2" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</symbol>
<symbol id="circle-half" viewBox="0 0 16 16" fill="currentcolor">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
</symbol>
<symbol id="moon-stars-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
<path
d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
</symbol>
<symbol id="sun-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</symbol>
</svg>
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em">
<use :href="icon"></use>
</svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#sun-fill"></use>
</svg>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#moon-stars-fill"></use>
</svg>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
Auto
</button>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<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'
export default {
mixins: [CommonMixins, MessagesMixins],
components: {
AboutMailpit,
AjaxLoader,
ListMessages,
NavMailbox,
NavTags,
Pagination,
SearchForm,
},
data() {
return {
mailbox,
}
},
mounted() {
mailbox.searching = false
this.apiURI = this.resolve(`/api/v1/messages`)
this.loadMessages()
},
}
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
<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">
<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 />
</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">
<i class="bi bi-list"></i>
</button>
</div>
<Pagination @loadMessages="loadMessages" :total="mailbox.total" />
</div>
</div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
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>
</div>
<div class="offcanvas-body">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
<AboutMailpit />
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
<AboutMailpit />
</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">
<ListMessages :loading-messages="loading" />
</div>
</div>
</div>
<NavMailbox @loadMessages="loadMessages" modals />
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
</template>

Some files were not shown because too many files have changed in this diff Show More