mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 06:27:01 +00:00
Compare commits
246 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c6f9d965 | ||
|
|
76a261bf06 | ||
|
|
86a3bea300 | ||
|
|
5fa6b20a53 | ||
|
|
3ad62769a6 | ||
|
|
a63952aee6 | ||
|
|
de95910539 | ||
|
|
60a41ce3ca | ||
|
|
898b36ce0b | ||
|
|
b4a4d44492 | ||
|
|
64e4e4240a | ||
|
|
0477c6573f | ||
|
|
28ac6d2099 | ||
|
|
43a1dbe3f0 | ||
|
|
aa3f860540 | ||
|
|
f54a2187ac | ||
|
|
063eab2c6a | ||
|
|
b282e6663b | ||
|
|
df777c6e90 | ||
|
|
8c4b1ac445 | ||
|
|
309c56566c | ||
|
|
12d47a0f82 | ||
|
|
27d601294a | ||
|
|
98343714be | ||
|
|
930901c4ec | ||
|
|
446cae145f | ||
|
|
6a4e5fb03c | ||
|
|
8f0549c596 | ||
|
|
4a762c502e | ||
|
|
9af04f83a3 | ||
|
|
8e0c174bf3 | ||
|
|
b193851269 | ||
|
|
95e346f8af | ||
|
|
582f1f88b2 | ||
|
|
0d084cfa1d | ||
|
|
aa0af5de32 | ||
|
|
ee49149df9 | ||
|
|
e18c45d0b3 | ||
|
|
87a68f6a53 | ||
|
|
6d35b7bc82 | ||
|
|
6cf7cba6b7 | ||
|
|
9788a01617 | ||
|
|
f4923c34ae | ||
|
|
b2ce855774 | ||
|
|
d489675c42 | ||
|
|
2ebaaa0fb2 | ||
|
|
80eba20679 | ||
|
|
1757a0086e | ||
|
|
e265d7018e | ||
|
|
a37da776d7 | ||
|
|
5baa598453 | ||
|
|
9d4bbe82e3 | ||
|
|
69226e91b2 | ||
|
|
8646efc979 | ||
|
|
7c42540427 | ||
|
|
c695cd23f6 | ||
|
|
bc53a34029 | ||
|
|
270d5f534f | ||
|
|
6a34c449a2 | ||
|
|
1723497c5c | ||
|
|
57e856f941 | ||
|
|
72d780fe66 | ||
|
|
4768b7b08c | ||
|
|
d01fb4044e | ||
|
|
8dbc661cb7 | ||
|
|
bc4b028c39 | ||
|
|
7875160aa7 | ||
|
|
f0c77ac962 | ||
|
|
5dbc585fce | ||
|
|
63fd86499d | ||
|
|
6db28c5ef7 | ||
|
|
92390a0999 | ||
|
|
149bfa80c2 | ||
|
|
6d2fab1bc6 | ||
|
|
93a6107df2 | ||
|
|
8c3705cc5d | ||
|
|
6379af5604 | ||
|
|
103bd564ab | ||
|
|
86a4633d24 | ||
|
|
80fa989a32 | ||
|
|
66850633a1 | ||
|
|
6c7a1d1ea2 | ||
|
|
0998595690 | ||
|
|
677b00e29a | ||
|
|
ba8b4366ce | ||
|
|
24fb49d079 | ||
|
|
c8a2effac4 | ||
|
|
9f63010ca5 | ||
|
|
f8d514e9e3 | ||
|
|
1922651d41 | ||
|
|
7d2716ee17 | ||
|
|
4c1df6f61e | ||
|
|
be3979241f | ||
|
|
aeb3585f3e | ||
|
|
b8de57da27 | ||
|
|
56982798dc | ||
|
|
ac0e7163dd | ||
|
|
7638500c05 | ||
|
|
5d63e9be9e | ||
|
|
672d9b7c26 | ||
|
|
d9be8f86d7 | ||
|
|
e3e827b180 | ||
|
|
daf6e453df | ||
|
|
9cb2c26c6f | ||
|
|
0aa8ea3d51 | ||
|
|
e05b284c2c | ||
|
|
d39b65deb7 | ||
|
|
7b8faa8a28 | ||
|
|
ebb98c99c0 | ||
|
|
a726cf9922 | ||
|
|
5d146a23d7 | ||
|
|
a6c1bbc977 | ||
|
|
d020861559 | ||
|
|
7fd3291040 | ||
|
|
479c74500c | ||
|
|
6b6de59c47 | ||
|
|
a5de4e4f65 | ||
|
|
48f22cca1f | ||
|
|
7748846b88 | ||
|
|
497086cb65 | ||
|
|
42ecadab9e | ||
|
|
4cfde7f947 | ||
|
|
70b604e028 | ||
|
|
8c295d4754 | ||
|
|
a1c34b37e1 | ||
|
|
e37583073e | ||
|
|
4de830c490 | ||
|
|
22a4509b13 | ||
|
|
1ed06161a8 | ||
|
|
a7ee479f06 | ||
|
|
93e8884ef7 | ||
|
|
1c228cda56 | ||
|
|
119b3864b2 | ||
|
|
b9f035790d | ||
|
|
1260c2e6df | ||
|
|
3431f18a3f | ||
|
|
2fa5138b49 | ||
|
|
652fec0f64 | ||
|
|
f168e11b05 | ||
|
|
35e81e0336 | ||
|
|
7beed988e5 | ||
|
|
4eea79f0c8 | ||
|
|
39767e979c | ||
|
|
4e2f02ee0a | ||
|
|
5a04534314 | ||
|
|
6725a809d5 | ||
|
|
64a067cff9 | ||
|
|
58dbccc0a7 | ||
|
|
3ef320d277 | ||
|
|
18e95b699e | ||
|
|
fc89655b7f | ||
|
|
ff9a6ff491 | ||
|
|
adce75ab8f | ||
|
|
12903cae60 | ||
|
|
7f55511c82 | ||
|
|
309036fb6d | ||
|
|
48387c3a13 | ||
|
|
a2ab350aff | ||
|
|
c150f1ba50 | ||
|
|
48bec0c8f6 | ||
|
|
fef2628c3f | ||
|
|
e5888ede8b | ||
|
|
374a760b88 | ||
|
|
0fdfa13a38 | ||
|
|
b41df78c4f | ||
|
|
870e523c97 | ||
|
|
0b391b5c37 | ||
|
|
c01f473e79 | ||
|
|
3c27fd715b | ||
|
|
714596a13a | ||
|
|
9ae02daf1a | ||
|
|
b6750600cb | ||
|
|
78e871e9b3 | ||
|
|
8ff2a5cf6a | ||
|
|
4a88d1fc24 | ||
|
|
d4268b8ae1 | ||
|
|
1b47716f5f | ||
|
|
42e6d71415 | ||
|
|
cd5789dda2 | ||
|
|
cd2a9d433a | ||
|
|
fe0dfe41e7 | ||
|
|
bee3174c78 | ||
|
|
a3187d5499 | ||
|
|
dc7f047b9a | ||
|
|
f3bb522143 | ||
|
|
3a41d56cc6 | ||
|
|
db5d8f672a | ||
|
|
3d96b2cad0 | ||
|
|
34c1748f4b | ||
|
|
52120abefd | ||
|
|
086142e977 | ||
|
|
078f42f4ea | ||
|
|
df5ded49b8 | ||
|
|
3bd1eca2ab | ||
|
|
95b54ce8a4 | ||
|
|
eb3330939d | ||
|
|
50b5f8667a | ||
|
|
a121c08dc4 | ||
|
|
9ff9b783cc | ||
|
|
7f68ea407b | ||
|
|
9a8e7ebdf9 | ||
|
|
db7f2c1a5d | ||
|
|
2ac0b40ecf | ||
|
|
d1edbe73b4 | ||
|
|
24e23790ec | ||
|
|
bc8722d1cf | ||
|
|
b1e3e1f879 | ||
|
|
635714945e | ||
|
|
1200750111 | ||
|
|
9670c4e1d5 | ||
|
|
1e97e9e21f | ||
|
|
ca31524487 | ||
|
|
4800922f91 | ||
|
|
6884cf34fc | ||
|
|
3b75bf3fa3 | ||
|
|
b4a971f552 | ||
|
|
e77d0a750d | ||
|
|
bdf887389e | ||
|
|
fdc1b05545 | ||
|
|
316b5d7c66 | ||
|
|
4f13785174 | ||
|
|
c83acfb255 | ||
|
|
1e8f10732e | ||
|
|
40bced067e | ||
|
|
f2bce03e9e | ||
|
|
34b62bd08a | ||
|
|
9d64e53b93 | ||
|
|
16bc025fff | ||
|
|
14a61859f0 | ||
|
|
304a379c30 | ||
|
|
82b0829429 | ||
|
|
25c393d380 | ||
|
|
b66f1d0ae1 | ||
|
|
5f919cc9dd | ||
|
|
225a1e2e2a | ||
|
|
6dca57ba9b | ||
|
|
60ea473acb | ||
|
|
0d9b0cdc43 | ||
|
|
e843de6166 | ||
|
|
b6f2618b34 | ||
|
|
31c0a501e8 | ||
|
|
08288e904d | ||
|
|
dfb455c59c | ||
|
|
5e00013a8d | ||
|
|
c5a8836b7e | ||
|
|
ae73c721db |
3
.github/workflows/release-build.yml
vendored
3
.github/workflows/release-build.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- run: npm run package
|
||||
|
||||
# build the binaries
|
||||
- uses: wangyoucao577/go-release-action@v1.37
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
@@ -44,4 +44,5 @@ jobs:
|
||||
extra_files: LICENSE README.md
|
||||
md5sum: false
|
||||
overwrite: true
|
||||
retry: 5
|
||||
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.18.x]
|
||||
go-version: [1.21.x]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
/node_modules/
|
||||
/send
|
||||
/sendmail/sendmail
|
||||
/server/ui/dist
|
||||
/Makefile
|
||||
/mailpit*
|
||||
/.idea
|
||||
*.old
|
||||
*.db
|
||||
|
||||
323
CHANGELOG.md
323
CHANGELOG.md
@@ -2,6 +2,329 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [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
|
||||
- Add pagination to swagger search documentation
|
||||
|
||||
### Fix
|
||||
- Check/set message Reply-To using SMTP FROM
|
||||
- Exclude "sendmail" from recipients list when using `mailpit sendmail <options>`
|
||||
- Exclude <script type="application/json"> from HTML check tests
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.8.0]
|
||||
|
||||
### Docs
|
||||
- Update brew installation instructions
|
||||
|
||||
### Feature
|
||||
- HTML check to test & score mail client compatibility with HTML emails
|
||||
|
||||
### Fix
|
||||
- Add basePath to swagger.json if webroot is specified
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger docs
|
||||
|
||||
### UI
|
||||
- Add flag to block all access to remote CSS and fonts (CSP)
|
||||
- Remove `<base />` tag if set in HTML preview
|
||||
- Pagination support for search, all results
|
||||
|
||||
|
||||
## [v1.7.1]
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Wrap HTML source lines
|
||||
- Dark mode color adjustments
|
||||
- Update dark mode loading background color
|
||||
|
||||
|
||||
## [v1.7.0]
|
||||
|
||||
### API
|
||||
- Ignore SMTP relay error when one of multiple recipients doesn't exist
|
||||
- Set raw message Content-Type to UTF-8
|
||||
|
||||
### Build
|
||||
- Define Vue build options in esbuild
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Theme toggler - auto, light and dark themes
|
||||
|
||||
|
||||
## [v1.6.22]
|
||||
|
||||
### Feature
|
||||
- Clearer SMTP error messages
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Upgrade node modules
|
||||
|
||||
|
||||
## [v1.6.21]
|
||||
|
||||
### UI
|
||||
- More accurate clickable hyperlink logic in plain text messages
|
||||
|
||||
|
||||
## [v1.6.20]
|
||||
|
||||
### Feature
|
||||
- Convert links into clickable hyperlinks in plain text message content
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.19]
|
||||
|
||||
### Fix
|
||||
- Only display sendmail help when sendmail subcommand is invoked
|
||||
|
||||
|
||||
## [v1.6.18]
|
||||
|
||||
### API
|
||||
- Sort tags before saving
|
||||
|
||||
### UI
|
||||
- Add option to enable tag colors based on tag name hash
|
||||
- Display message tags below subject in message overview
|
||||
|
||||
|
||||
## [v1.6.17]
|
||||
|
||||
### Fix
|
||||
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
|
||||
|
||||
|
||||
## [v1.6.16]
|
||||
|
||||
### Bugfix
|
||||
- Fix sendmail/startup panic
|
||||
|
||||
|
||||
## [v1.6.15]
|
||||
|
||||
### Feature
|
||||
- Add `sendmail -bs` functionality
|
||||
|
||||
|
||||
## [v1.6.14]
|
||||
|
||||
### Feature
|
||||
- Add ability to delete or mark search results read
|
||||
- Set tags via X-Tags message header
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.13]
|
||||
|
||||
### Feature
|
||||
- Add SMTP LOGIN authentication method for message relay
|
||||
|
||||
|
||||
## [v1.6.12]
|
||||
|
||||
### Feature
|
||||
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
|
||||
|
||||
### Swagger
|
||||
- Update swagger field descriptions, add MessageID
|
||||
|
||||
|
||||
## [v1.6.11]
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Check for secure context instead of HTTPS ([#114](https://github.com/axllent/mailpit/issues/114))
|
||||
|
||||
|
||||
## [v1.6.10]
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Remove "Noto Color Emoji" from default bootstrap font list
|
||||
|
||||
|
||||
## [v1.6.9]
|
||||
|
||||
### API
|
||||
- Return blank 200 response for OPTIONS requests (CORS)
|
||||
|
||||
### Bugfix
|
||||
- Correctly escape JS cid regex
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.6.8]
|
||||
|
||||
### Bugfix
|
||||
- Fix Date display when message doesn't contain a Date header
|
||||
|
||||
### Feature
|
||||
- Add allowlist to filter recipients before relaying messages ([#109](https://github.com/axllent/mailpit/issues/109))
|
||||
- Add `-S` short flag for sendmail `--smtp-addr`
|
||||
|
||||
|
||||
## [v1.6.7]
|
||||
|
||||
### Bugfix
|
||||
- Fix auto-deletion cron
|
||||
|
||||
|
||||
## [v1.6.6]
|
||||
|
||||
### API
|
||||
- Set Access-Control-Allow-Headers when --api-cors is set
|
||||
- Include correct start value in search reponse
|
||||
|
||||
### Feature
|
||||
- Option to ignore duplicate Message-IDs
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger field descriptions
|
||||
|
||||
### UI
|
||||
- Style Undisclosed recipients in message view
|
||||
|
||||
|
||||
## [v1.6.5]
|
||||
|
||||
### Feature
|
||||
- Add Access-Control-Allow-Methods methods when CORS origin is set
|
||||
|
||||
|
||||
## [v1.6.4]
|
||||
|
||||
### Bugfix
|
||||
- Fix UI images not displaying when multiple cid names overlap
|
||||
|
||||
|
||||
## [v1.6.3]
|
||||
|
||||
### Feature
|
||||
|
||||
51
README.md
51
README.md
@@ -6,11 +6,11 @@
|
||||

|
||||
[](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 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 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.
|
||||
|
||||

|
||||
|
||||
@@ -20,15 +20,19 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
- Runs entirely from a single binary, no installation required
|
||||
- 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
|
||||
- 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
|
||||
- Optional browser notifications for new mail (HTTPS only)
|
||||
- Optional browser notifications for new mail (when accessed via either HTTPS or `localhost` only)
|
||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
|
||||
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size, easily handling tens of thousands of emails
|
||||
- SMTP relaying / message release - relay messages via a different SMTP server ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
|
||||
- SMTP relaying / message release - relay messages via a different SMTP server including an optional allowlist of accepted recipients ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
|
||||
- Optional SMTP with STARTTLS & SMTP authentication, including an "accept anything" mode ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
|
||||
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
|
||||
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
|
||||
@@ -45,7 +49,7 @@ Mailpit runs as a single binary and can be installed in different ways:
|
||||
|
||||
### Install via Brew (Mac)
|
||||
|
||||
Add the repository to your taps with `brew tap axllent/apps`, and then install Mailpit with `brew install mailpit`.
|
||||
Install Mailpit with `brew install mailpit`.
|
||||
|
||||
|
||||
### Install via bash script (Linux & Mac)
|
||||
@@ -64,7 +68,7 @@ Static binaries can always be found on the [releases](https://github.com/axllent
|
||||
|
||||
### Docker
|
||||
|
||||
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images).
|
||||
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images) for 386, amd64 & arm64 images.
|
||||
|
||||
|
||||
### Compile from source
|
||||
@@ -72,26 +76,17 @@ See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Run `mailpit -h` to see options. More information can be seen in [the docs](https://github.com/axllent/mailpit/wiki/Runtime-options).
|
||||
|
||||
|
||||
|
||||
### Testing Mailpit
|
||||
|
||||
Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Testing-Mailpit) of how to easily test email delivery to Mailpit.
|
||||
|
||||
|
||||
### Configuring sendmail
|
||||
|
||||
There are several different options available:
|
||||
|
||||
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
|
||||
```
|
||||
sendmail_path = /usr/local/bin/mailpit sendmail
|
||||
```
|
||||
|
||||
If Mailpit is found on the same host as sendmail, you can symlink the Mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if Mailpit is running on default 1025 port).
|
||||
|
||||
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
|
||||
|
||||
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
|
||||
|
||||
|
||||
## Why rewrite MailHog?
|
||||
|
||||
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of performance issues, many of the frontend and Go modules are horribly out of date, and it is not actively developed.
|
||||
|
||||
Initially I tried to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect to its authors) poorly designed. It is in my opinion over-engineered (split over 9 separate projects), and performs very poorly when dealing with large amounts of emails or processing emails with an attachments (a single email with a 3MB attachment can take over a minute to ingest). Finally, the API transmits a lot of duplicate and unnecessary data on every browser request, and there is no HTTP compression.
|
||||
|
||||
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.
|
||||
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).
|
||||
|
||||
19
cmd/root.go
19
cmd/root.go
@@ -87,6 +87,9 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().BoolVar(&config.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.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
|
||||
@@ -97,11 +100,12 @@ 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().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
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!)")
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
|
||||
@@ -182,6 +186,9 @@ func initConfigFromEnv() {
|
||||
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 {
|
||||
@@ -201,8 +208,14 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
|
||||
config.IgnoreDuplicateIDs = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
|
||||
config.DisableHTMLCheck = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
smtpAddr = "localhost:1025"
|
||||
fromAddr string
|
||||
)
|
||||
|
||||
// sendmailCmd represents the sendmail command
|
||||
var sendmailCmd = &cobra.Command{
|
||||
Use: "sendmail [flags] [recipients]",
|
||||
Short: "A sendmail command replacement for Mailpit",
|
||||
Long: `A sendmail command replacement for Mailpit.
|
||||
|
||||
You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
sendmail.Run()
|
||||
},
|
||||
}
|
||||
@@ -25,13 +20,17 @@ You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
|
||||
func init() {
|
||||
rootCmd.AddCommand(sendmailCmd)
|
||||
|
||||
// these are simply repeated for cli consistency
|
||||
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
// print out manual help screen
|
||||
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
|
||||
|
||||
// these are simply repeated for cli consistency as cobra/viper does not allow
|
||||
// multi-letter single-dash variables (-bs)
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/mattn/go-shellwords"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/tg123/go-htpasswd"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
// UIAuthFile for basic authentication
|
||||
UIAuthFile string
|
||||
|
||||
// UIAuth used for euthentication
|
||||
// UIAuth used for authentication
|
||||
UIAuth *htpasswd.File
|
||||
|
||||
// Webroot to define the base path for the UI and API
|
||||
@@ -65,11 +65,20 @@ var (
|
||||
// SMTPAuthAcceptAny accepts any username/password including none
|
||||
SMTPAuthAcceptAny bool
|
||||
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// SMTPCLITags is used to map the CLI args
|
||||
SMTPCLITags string
|
||||
|
||||
// TagRegexp is the allowed tag characters
|
||||
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
|
||||
// SMTPTags are expressions to apply tags to new mail
|
||||
SMTPTags []AutoTag
|
||||
@@ -80,15 +89,19 @@ 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
|
||||
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via preconfgured SMTP server.
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
|
||||
// ContentSecurityPolicy for HTTP server
|
||||
ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';"
|
||||
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
|
||||
ContentSecurityPolicy string
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
@@ -108,19 +121,30 @@ type AutoTag struct {
|
||||
|
||||
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
|
||||
type smtpRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Auth string `yaml:"auth"` // none, plain, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allows overriding the boune address
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
|
||||
RecipientAllowlistRegexp *regexp.Regexp
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
cssFontRestriction := "*"
|
||||
if BlockRemoteCSSAndFonts {
|
||||
cssFontRestriction = "'self'"
|
||||
}
|
||||
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
|
||||
if DataFile != "" && isDir(DataFile) {
|
||||
DataFile = filepath.Join(DataFile, "mailpit.db")
|
||||
}
|
||||
@@ -203,19 +227,14 @@ 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, "=")
|
||||
if len(t) > 1 {
|
||||
tag := strings.TrimSpace(t[0])
|
||||
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
}
|
||||
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
|
||||
@@ -280,6 +299,11 @@ func parseRelayConfig(c string) error {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%s)", c)
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("SMTP relay host username or password not set for LOGIN authentication (%s)", c)
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
@@ -293,6 +317,18 @@ func parseRelayConfig(c string) error {
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile recipient allowlist regexp: %e", err)
|
||||
}
|
||||
|
||||
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
|
||||
logger.Log().Infof("[smtp] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Returns a JSON summary of the message and attachments.
|
||||
```json
|
||||
{
|
||||
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
|
||||
"Read": true,
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
@@ -31,6 +31,7 @@ Returns a JSON summary of the message and attachments.
|
||||
"ReplyTo": [],
|
||||
"Subject": "Message subject",
|
||||
"Date": "2016-09-07T16:46:00+13:00",
|
||||
"Tags": ["test"],
|
||||
"Text": "Plain text MIME part of the email",
|
||||
"HTML": "HTML MIME part (if exists)",
|
||||
"Size": 79499,
|
||||
@@ -56,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
|
||||
|
||||
@@ -29,11 +29,13 @@ List messages in the mailbox. Messages are returned in the order of latest recei
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"count": 50,
|
||||
"messages_count": 50,
|
||||
"start": 0,
|
||||
"tags": ["test"],
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
@@ -54,6 +56,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei
|
||||
"Bcc": [],
|
||||
"Subject": "Message subject",
|
||||
"Created": "2022-10-03T21:35:32.228605299+13:00",
|
||||
"Tags": ["test"],
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
@@ -66,7 +69,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei
|
||||
|
||||
- `total` - Total messages in mailbox
|
||||
- `unread` - Total unread messages in mailbox
|
||||
- `count` - Number of messages returned in request
|
||||
- `messages_count` - Total number of messages in mailbox
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `Read` - The read/unread status of the message
|
||||
- `From` - Name & Address, or null if none
|
||||
|
||||
@@ -25,11 +25,12 @@ Matching messages are returned in the order of latest received to oldest.
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"count": 25,
|
||||
"messages_count": 25,
|
||||
"start": 0,
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
@@ -62,7 +63,7 @@ Matching messages are returned in the order of latest received to oldest.
|
||||
|
||||
- `total` - Total messages in mailbox (all messages, not search)
|
||||
- `unread` - Total unread messages in mailbox (all messages, not search)
|
||||
- `count` - Number of messages returned in request
|
||||
- `messages_count` - Total number of messages matching search
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `From` - Singular Name & Address, or null if none
|
||||
- `To`, `CC`, `BCC` - Array of Name & Address
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 87 KiB |
@@ -14,6 +14,10 @@ const ctx = await esbuild.context(
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
loader: {
|
||||
|
||||
53
go.mod
53
go.mod
@@ -1,63 +1,70 @@
|
||||
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-20230922112808-5421fefb8386
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jhillyerd/enmime v0.11.1
|
||||
github.com/k3a/html2text v1.1.0
|
||||
github.com/klauspost/compress v1.16.5
|
||||
github.com/jhillyerd/enmime v1.0.1
|
||||
github.com/k3a/html2text v1.2.1
|
||||
github.com/klauspost/compress v1.17.0
|
||||
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.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
|
||||
golang.org/x/text v0.9.0
|
||||
github.com/vanng822/go-premailer v1.20.2
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/text v0.13.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.21.2
|
||||
modernc.org/sqlite v1.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
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/google/uuid v1.3.1 // 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
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.8.0 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/net v0.9.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
golang.org/x/tools v0.8.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/image v0.12.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/tools v0.13.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.4 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // 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.7.1 // 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
|
||||
)
|
||||
|
||||
129
go.sum
129
go.sum
@@ -4,6 +4,13 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcv
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
@@ -39,16 +46,21 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
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-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/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/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/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=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
@@ -57,16 +69,16 @@ 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 v0.11.1 h1:U6ToGVxfxNQQhKrAaGxtwOf7Zqksb8AQ3j1CyAWOk5k=
|
||||
github.com/jhillyerd/enmime v0.11.1/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
|
||||
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/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.1.0 h1:ks4hKSTdiTRsLr0DM771mI5TvsoG6zH7m1Ulv7eJRHw=
|
||||
github.com/k3a/html2text v1.1.0/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
|
||||
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
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.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/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=
|
||||
@@ -76,13 +88,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
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=
|
||||
@@ -93,7 +103,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -104,8 +117,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
|
||||
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.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
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=
|
||||
@@ -119,64 +132,86 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
|
||||
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-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.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||
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.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.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/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=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||
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.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
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/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=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.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=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.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 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
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.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
|
||||
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
||||
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
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=
|
||||
@@ -188,25 +223,25 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
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.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.22.4 h1:wymSbZb0AlrjdAVX3cjreCHTPCpPARbQXNz6BHPzdwQ=
|
||||
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
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.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M=
|
||||
modernc.org/memory v1.7.1/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.21.2 h1:ixuUG0QS413Vfzyx6FWx6PYTmHaOegTY+hjzhn7L+a0=
|
||||
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
|
||||
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.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=
|
||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
|
||||
1633
package-lock.json
generated
1633
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -5,23 +5,30 @@
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs"
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.4.41",
|
||||
"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": "^3.2.13",
|
||||
"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.17.5",
|
||||
"esbuild": "^0.19.1",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^2.3.2"
|
||||
}
|
||||
|
||||
@@ -3,31 +3,45 @@ package cmd
|
||||
|
||||
/**
|
||||
* Bare bones sendmail drop-in replacement borrowed from MailHog
|
||||
*
|
||||
* It uses a bit of a hack for flag parsing in order to be compatible
|
||||
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
|
||||
* is not POSIX compatible.
|
||||
*
|
||||
* The -bs command-line switch causes sendmail to run a single SMTP session in the
|
||||
* foreground over its standard input and output, and then exit. The SMTP session
|
||||
* is exactly like a network SMTP session. Usually, one or more messages are
|
||||
* submitted to sendmail for delivery.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/reiver/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
// Verbose flag
|
||||
Verbose bool
|
||||
// SMTPAddr address
|
||||
SMTPAddr = "localhost:1025"
|
||||
// FromAddr email address
|
||||
FromAddr string
|
||||
|
||||
fromAddr string
|
||||
// UseB - used to set from `-bs`
|
||||
UseB bool
|
||||
// UseS - used to set from `-bs`
|
||||
UseS bool
|
||||
)
|
||||
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
func init() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
host = "localhost"
|
||||
@@ -39,50 +53,76 @@ func Run() {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
if fromAddr == "" {
|
||||
fromAddr = username + "@" + host
|
||||
if FromAddr == "" {
|
||||
FromAddr = username + "@" + host
|
||||
}
|
||||
}
|
||||
|
||||
smtpAddr := "localhost:1025"
|
||||
var recip []string
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
var recipients []string
|
||||
|
||||
// defaults from envars if provided
|
||||
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
|
||||
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
|
||||
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
}
|
||||
|
||||
// override defaults from cli flags
|
||||
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender address")
|
||||
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
|
||||
flag.BoolVarP(&Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
flag.BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.CommandLine.SortFlags = false
|
||||
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
|
||||
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
|
||||
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolP("verbose", "v", false, "Ignored")
|
||||
flag.BoolP("long-i", "i", false, "Ignored")
|
||||
flag.BoolP("long-o", "o", false, "Ignored")
|
||||
flag.BoolP("long-t", "t", false, "Ignored")
|
||||
|
||||
// set the default help
|
||||
flag.Usage = func() {
|
||||
fmt.Printf("A sendmail command replacement for Mailpit (%s).\n\n", config.Version)
|
||||
fmt.Printf("Usage:\n %s [flags] [recipients]\n", os.Args[0])
|
||||
fmt.Println("\nFlags:")
|
||||
flag.PrintDefaults()
|
||||
fmt.Println(HelpTemplate(os.Args[0:1]))
|
||||
}
|
||||
|
||||
var showHelp bool
|
||||
// avoid 'pflag: help requested' error
|
||||
flag.BoolVarP(&showHelp, "help", "h", false, "")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// allow recipient to be passed as an argument
|
||||
recip = flag.Args()
|
||||
// allow recipients to be passed as an argument
|
||||
recipients = flag.Args()
|
||||
|
||||
if Verbose {
|
||||
fmt.Fprintln(os.Stdout, smtpAddr, fromAddr)
|
||||
// if run via `mailpit sendmail ...` then remove `sendmail` from "recipients"
|
||||
if len(recipients) > 0 && recipients[0] == "sendmail" {
|
||||
recipients = recipients[1:]
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(os.Stdin)
|
||||
if showHelp {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ensure -bs is set
|
||||
if UseB && !UseS || !UseB && UseS {
|
||||
fmt.Printf("error: use -bs")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// handles `sendmail -bs`
|
||||
if UseB && UseS {
|
||||
var caller telnet.Caller = telnet.StandardCaller
|
||||
|
||||
// telnet directly to SMTP
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading stdin")
|
||||
os.Exit(11)
|
||||
@@ -90,14 +130,14 @@ func Run() {
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("error parsing message body: %s", err))
|
||||
fmt.Fprintf(os.Stderr, "error parsing message body: %si\n", err)
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
addresses := []string{}
|
||||
|
||||
if len(recip) > 0 {
|
||||
addresses = recip
|
||||
if len(recipients) > 0 {
|
||||
addresses = recipients
|
||||
} else {
|
||||
// get all recipients in To, Cc and Bcc
|
||||
if to, err := msg.Header.AddressList("To"); err == nil {
|
||||
@@ -117,9 +157,28 @@ func Run() {
|
||||
}
|
||||
}
|
||||
|
||||
err = smtp.SendMail(smtpAddr, nil, fromAddr, addresses, body)
|
||||
err = smtp.SendMail(SMTPAddr, nil, FromAddr, addresses, body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// HelpTemplate returns a string of the help
|
||||
func HelpTemplate(args []string) string {
|
||||
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
|
||||
|
||||
Usage: %s [flags] [recipients] < message
|
||||
|
||||
See: https://github.com/axllent/mailpit
|
||||
|
||||
Flags:
|
||||
-S string SMTP server address (default "localhost:1025")
|
||||
-f string Set the envelope sender address (default "%s")
|
||||
-bs Handle SMTP commands on standard input
|
||||
-t Ignored
|
||||
-i Ignored
|
||||
-o Ignored
|
||||
-v Ignored
|
||||
`, config.Version, strings.Join(args, " "), FromAddr)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package apiv1 handles all the API responses
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/htmlcheck"
|
||||
"github.com/axllent/mailpit/utils/linkcheck"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -34,13 +37,13 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: pagination offset
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: limit results
|
||||
// description: Limit results
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
@@ -62,10 +65,11 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
@@ -88,12 +92,18 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// description: search query
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: limit results
|
||||
// description: Limit results
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
@@ -109,7 +119,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.Search(search, start, limit)
|
||||
messages, results, err := storage.Search(search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -119,10 +129,11 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = 0
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
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
|
||||
|
||||
@@ -131,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 MessagesSummary
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Deletes 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
|
||||
@@ -147,7 +196,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -188,12 +237,12 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: attachment part id
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -237,7 +286,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -284,7 +333,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -304,7 +353,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
@@ -330,7 +379,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message ids to delete
|
||||
// description: Database IDs to delete
|
||||
// required: false
|
||||
// type: DeleteRequest
|
||||
//
|
||||
@@ -357,7 +406,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
@@ -381,7 +430,7 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message ids to update
|
||||
// description: Database IDs to update
|
||||
// required: false
|
||||
// type: SetReadStatusRequest
|
||||
//
|
||||
@@ -440,6 +489,35 @@ 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 SetTags
|
||||
//
|
||||
// # 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
|
||||
@@ -459,7 +537,7 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message ids to update
|
||||
// description: Database IDs to update
|
||||
// required: true
|
||||
// type: SetTagsRequest
|
||||
//
|
||||
@@ -495,14 +573,14 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a preconfigured external SMTP server.
|
||||
// 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
|
||||
//
|
||||
// # Release message
|
||||
//
|
||||
// Release a message via a preconfigured external SMTP server..
|
||||
// Release a message via a pre-configured external SMTP server..
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -515,10 +593,10 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: To
|
||||
// + name: to
|
||||
// in: body
|
||||
// description: Array of email addresses to release message to
|
||||
// required: true
|
||||
@@ -554,10 +632,17 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for _, to := range tos {
|
||||
if _, err := mail.ParseAddress(to); err != nil {
|
||||
address, err := mail.ParseAddress(to)
|
||||
|
||||
if err != nil {
|
||||
httpError(w, "Invalid email address: "+to)
|
||||
return
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
httpError(w, "Mail address does not match allowlist: "+to)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(msg)
|
||||
@@ -615,6 +700,114 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// # HTML check (beta)
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = 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 LinkCheckResponse
|
||||
//
|
||||
// # 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
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: follow
|
||||
// in: query
|
||||
// description: Follow redirects
|
||||
// required: false
|
||||
// type: boolean
|
||||
// default: false
|
||||
//
|
||||
// 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")
|
||||
@@ -650,3 +843,10 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
|
||||
return start, limit
|
||||
}
|
||||
|
||||
// GetOptions returns a blank response
|
||||
func GetOptions(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(""))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package apiv1
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/htmlcheck"
|
||||
"github.com/axllent/mailpit/utils/linkcheck"
|
||||
)
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
@@ -12,9 +14,17 @@ type MessagesSummary struct {
|
||||
// Total number of unread messages in mailbox
|
||||
Unread int `json:"unread"`
|
||||
|
||||
// Number of results returned
|
||||
// Legacy - now undocumented in API specs but left for backwards compatibility.
|
||||
// Removed from API documentation 2023-07-12
|
||||
// swagger:ignore
|
||||
Count int `json:"count"`
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -37,3 +47,9 @@ type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
|
||||
// HTMLCheckResponse summary
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// LinkCheckResponse summary
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
@@ -67,7 +67,7 @@ type releaseMessageRequest struct {
|
||||
To []string `json:"to"`
|
||||
}
|
||||
|
||||
// Binary data reponse inherits the attachment's content type
|
||||
// Binary data response inherits the attachment's content type
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse struct {
|
||||
// in: body
|
||||
@@ -81,7 +81,7 @@ type textResponse struct {
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error reponse
|
||||
// Error response
|
||||
// swagger:response ErrorResponse
|
||||
type errorResponse struct {
|
||||
// The error message
|
||||
@@ -89,10 +89,14 @@ type errorResponse struct {
|
||||
Body string
|
||||
}
|
||||
|
||||
// Plain text "ok" reponse
|
||||
// Plain text "ok" response
|
||||
// swagger:response OKResponse
|
||||
type okResponse struct {
|
||||
// Default reponse
|
||||
// Default response
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// Plain JSON array response
|
||||
// swagger:response ArrayResponse
|
||||
type arrayResponse []string
|
||||
|
||||
@@ -39,12 +39,12 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: attachment part id
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
|
||||
@@ -20,16 +20,22 @@ type webUIConfiguration struct {
|
||||
SMTPServer string
|
||||
// Enforced Return-Path (if set) for relay bounces
|
||||
ReturnPath string
|
||||
// Allowlist of accepted recipients
|
||||
RecipientAllowlist string
|
||||
}
|
||||
|
||||
// Whether the HTML check has been globally disabled
|
||||
DisableHTMLCheck bool
|
||||
}
|
||||
|
||||
// WebUIConfig returns configuration settings for the web UI.
|
||||
func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/webui application WebUIConfiguration
|
||||
//
|
||||
// # Get web UI configuration
|
||||
//
|
||||
// Returns configuration settings for the web UI.
|
||||
// Intended for web UI only!
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -45,8 +51,11 @@ func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if config.ReleaseEnabled {
|
||||
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
|
||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
147
server/handlers/proxy.go
Normal file
147
server/handlers/proxy.go
Normal 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/utils/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)
|
||||
}
|
||||
129
server/server.go
129
server/server.go
@@ -2,19 +2,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/server/handlers"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -40,24 +44,33 @@ 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")
|
||||
}
|
||||
|
||||
// handle everything else with the virtual index.html
|
||||
r.PathPrefix(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
|
||||
|
||||
// put it all together
|
||||
http.Handle("/", r)
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
@@ -68,31 +81,44 @@ func Listen() {
|
||||
isReady.Store(true)
|
||||
|
||||
if config.UITLSCert != "" && config.UITLSKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
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")
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -122,6 +148,8 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
@@ -159,6 +187,8 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
@@ -194,4 +224,79 @@ 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
|
||||
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if config.Webroot != "/" {
|
||||
// artificially inject a path at the start
|
||||
replacement := fmt.Sprintf("{\n \"basePath\": \"%s\",", strings.TrimRight(config.Webroot, "/"))
|
||||
|
||||
f = bytes.Replace(f, []byte("{"), []byte(replacement), 1)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// f, err := embeddedFS.ReadFile("public/index.html")
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -29,7 +29,7 @@ func Test_APIv1(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := defaultRoutes()
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -57,8 +57,8 @@ func Test_APIv1(t *testing.T) {
|
||||
// read first 10
|
||||
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
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.Count, "wrong search results count")
|
||||
assertEqual(t, count, m.MessagesCount, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
@@ -253,7 +253,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 +278,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 +303,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
|
||||
}
|
||||
|
||||
@@ -2,19 +2,53 @@ package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
)
|
||||
|
||||
func allowedRecipients(to []string) []string {
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
|
||||
return to
|
||||
}
|
||||
|
||||
var ar []string
|
||||
|
||||
for _, recipient := range to {
|
||||
address, err := mail.ParseAddress(recipient)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid email address: %s", recipient)
|
||||
continue
|
||||
}
|
||||
|
||||
if !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.RecipientAllowlist)
|
||||
} else {
|
||||
ar = append(ar, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
return ar
|
||||
}
|
||||
|
||||
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Send(from string, to []string, msg []byte) error {
|
||||
recipients := allowedRecipients(to)
|
||||
|
||||
if len(recipients) == 0 {
|
||||
return errors.New("no valid recipients")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
@@ -25,7 +59,7 @@ func Send(from string, to []string, msg []byte) error {
|
||||
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,37 +69,72 @@ func Send(from string, to []string, msg []byte) error {
|
||||
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 {
|
||||
return err
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return err
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Custom implementation of LOGIN SMTP authentication
|
||||
// @see https://gist.github.com/andelf/5118732
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
// LoginAuth authentication
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("Unknown fromServer")
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -24,18 +30,46 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// check / set the Return-Path based on SMTP from
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
if returnPath != from {
|
||||
if returnPath != "" {
|
||||
// replace Return-Path
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
|
||||
})
|
||||
} else {
|
||||
// add Return-Path
|
||||
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
|
||||
|
||||
// add a message ID if not set
|
||||
if msg.Header.Get("Message-Id") == "" {
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
uid := uuid.NewV4().String() + "@mailpit"
|
||||
messageID = uuid.NewV4().String() + "@mailpit"
|
||||
// add unique ID
|
||||
data = append([]byte("Message-Id: <"+uid+">\r\n"), data...)
|
||||
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
|
||||
} else if config.IgnoreDuplicateIDs {
|
||||
if storage.MessageIDExists(messageID) {
|
||||
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// if enabled, this will route the email 1:1 through to the preconfigured smtp server
|
||||
if config.SMTPRelayAllIncoming {
|
||||
if err := Send(from, to, data); err != nil {
|
||||
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
|
||||
logger.Log().Warnf("[smtp] error relaying message: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] relayed message from %s via %s:%d", from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
@@ -67,7 +101,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurence
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
|
||||
})
|
||||
@@ -81,8 +115,9 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
|
||||
}
|
||||
|
||||
if _, err := storage.Store(data); err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %d", err.Error())
|
||||
_, err = storage.Store(data)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -93,7 +128,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
|
||||
allow := config.SMTPAuthConfig.Match(string(username), string(password))
|
||||
if allow {
|
||||
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
@@ -105,7 +140,7 @@ func authHandler(remoteAddr net.Addr, mechanism string, username []byte, passwor
|
||||
}
|
||||
|
||||
// Allow any username and password
|
||||
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
|
||||
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
|
||||
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
|
||||
return true, nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import "./assets/styles.scss";
|
||||
import "../../node_modules/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')
|
||||
|
||||
51
server/ui-src/assets/_bootstrap.scss
Normal file
51
server/ui-src/assets/_bootstrap.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
// @import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
@import "bootstrap/scss/card";
|
||||
@import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
// @import "bootstrap/scss/popover";
|
||||
// @import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas";
|
||||
// @import "bootstrap/scss/popover";
|
||||
@import "bootstrap/scss/progress";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1,3 +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";
|
||||
|
||||
$link-decoration: none;
|
||||
$primary: #2c3e50;
|
||||
$list-group-disabled-color: #adb5bd;
|
||||
$enable-negative-margins: true;
|
||||
$body-color-dark: #e7eaed;
|
||||
$offcanvas-border-width: 0;
|
||||
|
||||
49
server/ui-src/assets/bootstrap.scss
vendored
49
server/ui-src/assets/bootstrap.scss
vendored
@@ -1,49 +0,0 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "../../../node_modules/bootstrap/scss/functions";
|
||||
@import "../../../node_modules/bootstrap/scss/variables";
|
||||
@import "../../../node_modules/bootstrap/scss/maps";
|
||||
@import "../../../node_modules/bootstrap/scss/mixins";
|
||||
@import "../../../node_modules/bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "../../../node_modules/bootstrap/scss/root";
|
||||
@import "../../../node_modules/bootstrap/scss/reboot";
|
||||
@import "../../../node_modules/bootstrap/scss/type";
|
||||
@import "../../../node_modules/bootstrap/scss/images";
|
||||
@import "../../../node_modules/bootstrap/scss/containers";
|
||||
@import "../../../node_modules/bootstrap/scss/grid";
|
||||
// @import "../../../node_modules/bootstrap/scss/tables";
|
||||
@import "../../../node_modules/bootstrap/scss/forms";
|
||||
@import "../../../node_modules/bootstrap/scss/buttons";
|
||||
// @import "../../../node_modules/bootstrap/scss/transitions";
|
||||
@import "../../../node_modules/bootstrap/scss/dropdown";
|
||||
@import "../../../node_modules/bootstrap/scss/button-group";
|
||||
@import "../../../node_modules/bootstrap/scss/nav";
|
||||
@import "../../../node_modules/bootstrap/scss/navbar";
|
||||
@import "../../../node_modules/bootstrap/scss/card";
|
||||
// @import "../../../node_modules/bootstrap/scss/accordion";
|
||||
// @import "../../../node_modules/bootstrap/scss/breadcrumb";
|
||||
// @import "../../../node_modules/bootstrap/scss/pagination";
|
||||
@import "../../../node_modules/bootstrap/scss/badge";
|
||||
// @import "../../../node_modules/bootstrap/scss/alert";
|
||||
// @import "../../../node_modules/bootstrap/scss/progress";
|
||||
@import "../../../node_modules/bootstrap/scss/list-group";
|
||||
@import "../../../node_modules/bootstrap/scss/close";
|
||||
@import "../../../node_modules/bootstrap/scss/toasts";
|
||||
@import "../../../node_modules/bootstrap/scss/modal";
|
||||
// @import "../../../node_modules/bootstrap/scss/tooltip";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
// @import "../../../node_modules/bootstrap/scss/carousel";
|
||||
@import "../../../node_modules/bootstrap/scss/spinners";
|
||||
// @import "../../../node_modules/bootstrap/scss/offcanvas";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
|
||||
// Helpers
|
||||
@import "../../../node_modules/bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "../../../node_modules/bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1,333 +1,382 @@
|
||||
@import "bootstrap";
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message.read:not(.active):not(.selected) {
|
||||
color: $gray-500;
|
||||
.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) {
|
||||
.loader {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
&.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;
|
||||
}
|
||||
|
||||
#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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.message.selected {
|
||||
background: $gray-300;
|
||||
|
||||
.text-muted {
|
||||
color: $body-color !important;
|
||||
}
|
||||
|
||||
&.read {
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -335,10 +384,10 @@ pre[class*="language-"] {
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: #7d8b99;
|
||||
color: #7d8b99;
|
||||
}
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
color: #5f6364;
|
||||
}
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
@@ -348,7 +397,7 @@ pre[class*="language-"] {
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: #c92c2c;
|
||||
color: #c92c2c;
|
||||
}
|
||||
.token.attr-name,
|
||||
.token.builtin,
|
||||
@@ -357,70 +406,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;
|
||||
}
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
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;
|
||||
}
|
||||
|
||||
239
server/ui-src/components/AboutMailpit.vue
Normal file
239
server/ui-src/components/AboutMailpit.vue
Normal 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>
|
||||
17
server/ui-src/components/AjaxLoader.vue
Normal file
17
server/ui-src/components/AjaxLoader.vue
Normal 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>
|
||||
166
server/ui-src/components/ListMessages.vue
Normal file
166
server/ui-src/components/ListMessages.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
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><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
|
||||
<div>
|
||||
<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>
|
||||
<!-- </a> -->
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<template v-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
139
server/ui-src/components/NavMailbox.vue
Normal file
139
server/ui-src/components/NavMailbox.vue
Normal 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.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="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>
|
||||
103
server/ui-src/components/NavSearch.vue
Normal file
103
server/ui-src/components/NavSearch.vue
Normal 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>
|
||||
120
server/ui-src/components/NavSelected.vue
Normal file
120
server/ui-src/components/NavSelected.vue
Normal 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>
|
||||
55
server/ui-src/components/NavTags.vue
Normal file
55
server/ui-src/components/NavTags.vue
Normal 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(`\\btag:"?${tag}"?\\b`, '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>
|
||||
176
server/ui-src/components/Notifications.vue
Normal file
176
server/ui-src/components/Notifications.vue
Normal 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>
|
||||
92
server/ui-src/components/Pagination.vue
Normal file
92
server/ui-src/components/Pagination.vue
Normal 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>
|
||||
64
server/ui-src/components/SearchForm.vue
Normal file
64
server/ui-src/components/SearchForm.vue
Normal 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>
|
||||
41
server/ui-src/components/message/Attachments.vue
Normal file
41
server/ui-src/components/message/Attachments.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
|
||||
<img v-if="isImage(part)" :src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
|
||||
class="card-img-top" alt="">
|
||||
<img v-else
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||
class="card-img-top" alt="">
|
||||
<div class="icon" v-if="!isImage(part)">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="card-body border-0">
|
||||
<p class="mb-1">
|
||||
<i class="bi me-1" :class="attachmentIcon(part)"></i>
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
670
server/ui-src/components/message/HTMLCheck.vue
Normal file
670
server/ui-src/components/message/HTMLCheck.vue
Normal file
@@ -0,0 +1,670 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
enabled: true,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
"windows": "Windows",
|
||||
"windows-mail": "Windows Mail",
|
||||
"outlook-com": "Outlook.com",
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
"android": "Android",
|
||||
"desktop-webmail": "Desktop Webmail",
|
||||
"mobile-webmail": "Mobile Webmail",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.enabled = !localStorage.getItem('htmlCheckDisabled')
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
},
|
||||
|
||||
computed: {
|
||||
summary: function () {
|
||||
let self = this
|
||||
|
||||
if (!this.enabled || !this.check) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = {
|
||||
Warnings: [],
|
||||
Total: {
|
||||
Nodes: this.check.Total.Nodes
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
||||
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
|
||||
|
||||
// for <script> test
|
||||
if (o.Results.length == 0) {
|
||||
result.Warnings.push(o)
|
||||
continue
|
||||
}
|
||||
|
||||
// filter by enabled platforms
|
||||
let results = o.Results.filter(function (w) {
|
||||
return self.platforms.indexOf(w.Platform) != -1
|
||||
})
|
||||
|
||||
if (results.length == 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// recalculate the percentages
|
||||
let y = 0, p = 0, n = 0
|
||||
|
||||
results.forEach(function (r) {
|
||||
if (r.Support == "yes") {
|
||||
y++
|
||||
} else if (r.Support == "partial") {
|
||||
p++
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
})
|
||||
let total = y + p + n
|
||||
o.Results = results
|
||||
o.Score = {
|
||||
Found: o.Score.Found,
|
||||
Supported: y / total * 100,
|
||||
Partial: p / total * 100,
|
||||
Unsupported: n / total * 100
|
||||
}
|
||||
|
||||
result.Warnings.push(o)
|
||||
}
|
||||
|
||||
let maxPartial = 0, maxUnsupported = 0
|
||||
result.Warnings.forEach(function (w) {
|
||||
let scoreWeight = 1
|
||||
if (w.Score.Found < result.Total.Nodes) {
|
||||
// each error is weighted based on the number of occurrences vs: the total message nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes
|
||||
}
|
||||
|
||||
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
|
||||
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||
// off the calculation as these dominate.
|
||||
if (self.isPseudoClassOrAtRule(w.Title)) {
|
||||
scoreWeight = 0.05
|
||||
w.PseudoClassOrAtRule = true
|
||||
}
|
||||
|
||||
let scorePartial = w.Score.Partial * scoreWeight
|
||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
||||
if (scorePartial > maxPartial) {
|
||||
maxPartial = scorePartial
|
||||
}
|
||||
if (scoreUnsupported > maxUnsupported) {
|
||||
maxUnsupported = scoreUnsupported
|
||||
}
|
||||
})
|
||||
|
||||
// sort warnings by final score
|
||||
result.Warnings.sort(function (a, b) {
|
||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
||||
|
||||
if (self.isPseudoClassOrAtRule(a.Title)) {
|
||||
aWeight = 0.05
|
||||
}
|
||||
|
||||
if (self.isPseudoClassOrAtRule(b.Title)) {
|
||||
bWeight = 0.05
|
||||
}
|
||||
|
||||
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
})
|
||||
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported
|
||||
result.Total.Partial = maxPartial
|
||||
result.Total.Unsupported = maxUnsupported
|
||||
|
||||
this.$emit('setHtmlScore', result.Total.Supported)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
graphSections: function () {
|
||||
let s = Math.round(this.summary.Total.Supported)
|
||||
let p = Math.round(this.summary.Total.Partial)
|
||||
let u = 100 - s - p
|
||||
return [
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
||||
value: s,
|
||||
color: '#198754'
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
|
||||
value: p,
|
||||
color: '#ffc107'
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
|
||||
value: u,
|
||||
color: '#dc3545'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// colors depend on both varying unsupported & partially unsupported percentages
|
||||
scoreColor: function () {
|
||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||
this.$emit('setBadgeStyle', 'bg-success')
|
||||
return 'text-success'
|
||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
||||
return 'text-warning'
|
||||
}
|
||||
|
||||
this.$emit('setBadgeStyle', 'bg-danger')
|
||||
return 'text-danger'
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setHtmlScore', false)
|
||||
this.doCheck()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
},
|
||||
enabled(v) {
|
||||
if (!v) {
|
||||
localStorage.setItem('htmlCheckDisabled', true)
|
||||
this.$emit('setHtmlScore', false)
|
||||
} else {
|
||||
localStorage.removeItem('htmlCheckDisabled')
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck: function () {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.check = false
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
return
|
||||
}
|
||||
|
||||
let self = this
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
|
||||
.then(function (result) {
|
||||
self.check = result.data
|
||||
self.error = false
|
||||
|
||||
// set tooltips
|
||||
window.setTimeout(function () {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
}, 500)
|
||||
})
|
||||
.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
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadConfig: function () {
|
||||
let platforms = localStorage.getItem('html-check-platforms')
|
||||
if (platforms) {
|
||||
try {
|
||||
this.platforms = JSON.parse(platforms)
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// set all options
|
||||
if (this.platforms.length == 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms)
|
||||
}
|
||||
},
|
||||
|
||||
// return a platform's families (email clients)
|
||||
families: function (k) {
|
||||
if (this.check.Platforms[k]) {
|
||||
return this.check.Platforms[k]
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
|
||||
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||
isPseudoClassOrAtRule: function (t) {
|
||||
return t.match(/^(:|@)/)
|
||||
},
|
||||
|
||||
round: function (v) {
|
||||
return Math.round(v)
|
||||
},
|
||||
|
||||
round2dm: function (v) {
|
||||
return Math.round(v * 100) / 100
|
||||
},
|
||||
|
||||
scrollToWarnings: function () {
|
||||
if (!this.$refs.warnings) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="error">
|
||||
<p>HTML check failed to load:</p>
|
||||
<div class="alert alert-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!enabled">
|
||||
<h2 class="h4 text-secondary">HTML check is currently disabled</h2>
|
||||
<p class="text-secondary">
|
||||
This feature is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck">
|
||||
<label class="form-check-label" for="inlineEnableHTMLCheck">
|
||||
Enable HTML check
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="summary">
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
|
||||
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
|
||||
@section-click="scrollToWarnings">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ round2dm(summary.Total.Supported) }}%
|
||||
</h2>
|
||||
<div class="text-body">
|
||||
support
|
||||
</div>
|
||||
<template #legend>
|
||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-success"></i>
|
||||
{{ round2dm(summary.Total.Supported) }}% supported
|
||||
</span>
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-warning"></i>
|
||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||
</span>
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-danger"></i>
|
||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-secondary">
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
</template>
|
||||
</Donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults">
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
||||
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
|
||||
:aria-label="p" :id="'Check_' + k">
|
||||
<label class="form-check-label" :for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-secondary'" :title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
||||
{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="summary.Warnings.length">
|
||||
<h4 ref="warnings" class="h5 mt-4">
|
||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||
</h4>
|
||||
<div class="accordion" id="warnings">
|
||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
||||
<div class="row w-100 w-lg-75">
|
||||
<div class="col-sm">
|
||||
{{ warning.Title }}
|
||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||
{{ warning.Category }}
|
||||
</span>
|
||||
<span class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected">
|
||||
x {{ warning.Score.Found }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm mt-2 mt-sm-0">
|
||||
<div class="progress-stacked">
|
||||
<div class="progress" role="progressbar" aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
|
||||
<div class="progress-bar bg-success">
|
||||
{{ round(warning.Score.Supported) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
||||
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||
{{ round(warning.Score.Partial) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
|
||||
<div class="progress-bar bg-danger">
|
||||
{{ round(warning.Score.Unsupported) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||
<div class="accordion-body">
|
||||
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
|
||||
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
||||
v-else>ies</template> in the CSS styles, but unable to test if used or not.
|
||||
</span>
|
||||
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
|
||||
</p>
|
||||
|
||||
<template v-if="warning.Results.length">
|
||||
<h3 class="h6">Clients with partial or no support:</h3>
|
||||
<p>
|
||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
||||
<i class="bi bi-circle-fill"
|
||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
||||
{{ warning.Name }}
|
||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
||||
title="See notes">
|
||||
{{ warning.NoteNumber }}
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||
<h3 class="h6">Notes:</h3>
|
||||
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
|
||||
<div class="col-auto pe-0">
|
||||
<span class="badge text-bg-secondary">
|
||||
{{ i }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col" v-html="n"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small mt-3 mb-0" v-if="warning.URL">
|
||||
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-secondary small mt-4">
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
|
||||
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML 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="HTMLCheckAboutAccordion">
|
||||
<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 HTML check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
The support for HTML/CSS messages varies greatly across email clients. HTML check
|
||||
attempts to calculate the overall support for your email for all selected platforms
|
||||
to give you some idea of the general compatibility of your HTML email.
|
||||
</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">
|
||||
How does it work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Internally the original HTML message is run against
|
||||
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
|
||||
(except for <code><script></code>) correspond to a test on
|
||||
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
|
||||
final score is calculated using the available compatibility data.
|
||||
</p>
|
||||
<p>
|
||||
CSS support is very difficult to programmatically test, especially if a message
|
||||
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
|
||||
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
|
||||
and injected into the message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
|
||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||
</p>
|
||||
<p>
|
||||
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
|
||||
<code>:hover</code> etc cannot be inlined however, so these are searched for
|
||||
within CSS blocks. This method is not accurate as Mailpit does not know how many
|
||||
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
|
||||
to affect the score. An example of this would be any email linking to the full
|
||||
bootstrap CSS which contains dozens of unused attributes.
|
||||
</p>
|
||||
<p>
|
||||
All warnings are displayed with their respective support, including any specific
|
||||
notes, and it is up to you to decide what you do with that information and how
|
||||
badly it may impact your message.
|
||||
</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">
|
||||
Is the final score accurate?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
There are many ways to define "accurate", and how one should calculate the
|
||||
compatibility score of an email. There is also no way to programmatically
|
||||
determine the relevance of a single test to the entire email.
|
||||
</p>
|
||||
<p>
|
||||
For each test, Mailpit calculates both the unsupported & partially-supported
|
||||
percentages in relation to the number of matches against the total number of
|
||||
nodes (elements) in the HTML. The maximum unsupported and partially-supported
|
||||
weighted scores are then used for the final score (ie: worst case scenario).
|
||||
</p>
|
||||
<p>
|
||||
To try explain this logic in very simple terms: Assuming a
|
||||
<code><script></code> node (element) has 100% failure (not supported in
|
||||
any email client), and a <code><p></code> node has 100% pass (supported).
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
An email containing just a single <code><script></code>: the final
|
||||
score is 0% supported.
|
||||
</li>
|
||||
<li>
|
||||
An email containing just a <code><script></code> and a
|
||||
<code><p></code>: the final score is 50% supported.
|
||||
</li>
|
||||
<li>
|
||||
An email containing just a <code><script></code> and two
|
||||
<code><p></code>: the final score is 66.67% supported.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Mailpit will sort the warnings according to their weighted unsupported scores.
|
||||
</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="#col4" aria-expanded="false" aria-controls="col4">
|
||||
What about invalid HTML?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
HTML check does not detect if the original HTML is valid. In order to detect applied
|
||||
styles to every node, the HTML email is run through a parser which is very good at
|
||||
turning invalid input into valid output. It is what it is...
|
||||
</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>
|
||||
|
||||
<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" 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="HTMLCheckOptionsLabel">HTML check options</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML 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="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled"
|
||||
id="HTMLCheckSwitch">
|
||||
<label class="form-check-label" for="HTMLCheckSwitch">
|
||||
<template v-if="enabled">HTML check is enabled in the web UI</template>
|
||||
<template v-else>HTML check is disabled in the web UI</template>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-4 small text-center text-secondary">
|
||||
HTML check can be globally disabled with <code>--disable-html-check</code><br>
|
||||
Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../mixins.js';
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -17,9 +17,9 @@ 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;
|
||||
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-muted">
|
||||
<div v-for="x in vals" class="mb-2 text-break">{{ x }}</div>
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
|
||||
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
398
server/ui-src/components/message/LinkCheck.vue
Normal file
398
server/ui-src/components/message/LinkCheck.vue
Normal 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 & 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 & 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>
|
||||
480
server/ui-src/components/message/Message.vue
Normal file
480
server/ui-src/components/message/Message.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
import HTMLCheck from './HTMLCheck.vue'
|
||||
import Headers from './Headers.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,
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
canSaveTags: false, // prevent auto-saving tags on render
|
||||
messageTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
linkCheckErrors: false,
|
||||
showMobileButtons: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
phone: 'width: 322px; height: 570px',
|
||||
tablet: 'width: 768px; height: 1024px',
|
||||
display: 'width: 100%; height: 100%',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
messageTags() {
|
||||
if (this.canSaveTags) {
|
||||
// save changes to tags
|
||||
this.saveTags()
|
||||
}
|
||||
},
|
||||
|
||||
scaleHTMLPreview(v) {
|
||||
if (v == 'display') {
|
||||
let self = this
|
||||
window.setTimeout(function () {
|
||||
self.resizeIFrames()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let self = this
|
||||
self.canSaveTags = false
|
||||
self.messageTags = self.message.Tags
|
||||
self.renderUI()
|
||||
|
||||
window.addEventListener("resize", self.resizeIFrames)
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab')
|
||||
headersTab.addEventListener('shown.bs.tab', function (event) {
|
||||
self.loadHeaders = true
|
||||
})
|
||||
|
||||
let rawTab = document.getElementById('nav-raw-tab')
|
||||
rawTab.addEventListener('shown.bs.tab', function (event) {
|
||||
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
|
||||
self.resizeIFrames()
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
isHTMLTabSelected: function () {
|
||||
this.showMobileButtons = this.$refs.navhtml
|
||||
&& this.$refs.navhtml.classList.contains('active')
|
||||
},
|
||||
|
||||
renderUI: function () {
|
||||
let self = this
|
||||
|
||||
// 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
|
||||
|
||||
self.isHTMLTabSelected()
|
||||
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
|
||||
listObj.addEventListener('shown.bs.tab', function (event) {
|
||||
self.isHTMLTabSelected()
|
||||
})
|
||||
})
|
||||
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
window.setTimeout(function () {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p) {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^http/)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
self.resizeIFrames()
|
||||
}
|
||||
}, 200)
|
||||
|
||||
// html highlighting
|
||||
window.Prism = window.Prism || {}
|
||||
window.Prism.manual = true
|
||||
Prism.highlightAll()
|
||||
},
|
||||
|
||||
resizeIframe: function (el) {
|
||||
let i = el.target
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
},
|
||||
|
||||
resizeIFrames: function () {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return
|
||||
}
|
||||
let h = document.getElementById('preview-html')
|
||||
if (h) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// set the iframe body & text colors based on current theme
|
||||
initRawIframe: function (el) {
|
||||
let bodyStyles = window.getComputedStyle(document.body, null)
|
||||
let bg = bodyStyles.getPropertyValue('background-color')
|
||||
let txt = bodyStyles.getPropertyValue('color')
|
||||
|
||||
let body = el.target.contentWindow.document.querySelector('body')
|
||||
if (body) {
|
||||
body.style.color = txt
|
||||
body.style.backgroundColor = bg
|
||||
}
|
||||
|
||||
this.resizeIframe(el)
|
||||
},
|
||||
|
||||
sanitizeHTML: function (h) {
|
||||
// remove <base/> tag if set
|
||||
return h.replace(/<base .*>/mi, '')
|
||||
},
|
||||
|
||||
saveTags: function () {
|
||||
let self = this
|
||||
|
||||
var data = {
|
||||
ids: [this.message.ID],
|
||||
tags: this.messageTags
|
||||
}
|
||||
|
||||
self.put(self.resolve('/api/v1/tags'), data, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.$emit('loadMessages')
|
||||
})
|
||||
},
|
||||
|
||||
// Convert plain text to HTML including anchor links
|
||||
textToHTML: function (s) {
|
||||
let html = s
|
||||
|
||||
// full links with http(s)
|
||||
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
|
||||
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
|
||||
|
||||
// plain www links without https?:// prefix
|
||||
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
|
||||
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
|
||||
|
||||
// escape to HTML & convert <>" back
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/˱˱˱/g, '<')
|
||||
.replace(/˲˲˲/g, '>')
|
||||
.replace(/ˠˠˠ/g, '"')
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" style="overflow-y: scroll;">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||
{{ message.From.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span>
|
||||
{{ t.Name }}
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</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 text-break">
|
||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||
{{ message.ReturnPath }}
|
||||
</a>>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''">{{ message.Subject }}</strong>
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-md-none small">
|
||||
<th class="small">Date</th>
|
||||
<td>{{ messageDate(message.Date) }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
||||
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_]){3,}$"
|
||||
data-separator="|,|">
|
||||
<option value="">Type a tag...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3">
|
||||
<div class="mt-2 mt-md-0" v-if="allAttachments(message)">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2">
|
||||
Attachment<span v-if="allAttachments(message).length > 1">s</span>
|
||||
({{ allAttachments(message).length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
</button>
|
||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
||||
HTML Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
Text
|
||||
</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div class="dropdown d-xl-none">
|
||||
<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">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content mb-5" id="nav-tabContent">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizeHTML(message.HTML)"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
|
||||
style="width: 100%; height: 300px"></iframe>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="!mailbox.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>
|
||||
135
server/ui-src/components/message/Release.vue
Normal file
135
server/ui-src/components/message/Release.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
|
||||
<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,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
addresses: [],
|
||||
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()
|
||||
})
|
||||
}, 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="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>
|
||||
147
server/ui-src/components/message/Screenshot.vue
Normal file
147
server/ui-src/components/message/Screenshot.vue
Normal 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>
|
||||
@@ -1,66 +1,88 @@
|
||||
import axios from 'axios';
|
||||
import { Modal } from 'bootstrap';
|
||||
import moment from 'moment';
|
||||
import axios from 'axios'
|
||||
import moment from 'moment'
|
||||
import ColorHash from 'color-hash'
|
||||
import { Modal, Offcanvas } from 'bootstrap'
|
||||
|
||||
// BootstrapElement is used to return a fake Bootstrap element
|
||||
// if the ID returns nothing to prevent errors.
|
||||
class BootstrapElement {
|
||||
constructor() { }
|
||||
hide() { }
|
||||
show() { }
|
||||
}
|
||||
|
||||
// FakeModal is used to return a fake Bootstrap modal
|
||||
// if the ID returns nothing
|
||||
function FakeModal() { }
|
||||
FakeModal.prototype.hide = function () { alert('close fake modal') }
|
||||
FakeModal.prototype.show = function () { alert('open fake modal') }
|
||||
// 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] })
|
||||
|
||||
/* Common mixin functions used in apps */
|
||||
const commonMixins = {
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: 0
|
||||
loading: 0,
|
||||
tagColorCache: {},
|
||||
}
|
||||
},
|
||||
|
||||
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()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -69,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--
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -92,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--
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -113,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--
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -134,76 +163,104 @@ 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.
|
||||
// Values are stored in an array for faster lookup / processing.
|
||||
colorHash: function (s) {
|
||||
if (this.tagColorCache[s] != undefined) {
|
||||
return this.tagColorCache[s]
|
||||
}
|
||||
this.tagColorCache[s] = colorHash.hex(s)
|
||||
|
||||
return this.tagColorCache[s]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default commonMixins;
|
||||
90
server/ui-src/mixins/MessagesMixins.js
Normal file
90
server/ui-src/mixins/MessagesMixins.js
Normal 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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
37
server/ui-src/router/index.js
Normal file
37
server/ui-src/router/index.js
Normal 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
|
||||
59
server/ui-src/stores/mailbox.js
Normal file
59
server/ui-src/stores/mailbox.js
Normal 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')
|
||||
}
|
||||
}
|
||||
)
|
||||
8
server/ui-src/stores/pagination.js
Normal file
8
server/ui-src/stores/pagination.js
Normal 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
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../mixins.js';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="'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'" class="card-img-top" alt="">
|
||||
<img v-else src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" class="card-img-top" alt="">
|
||||
<div class="icon" v-if="!isImage(part)">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="card-body border-0">
|
||||
<p class="mb-1 text-muted">
|
||||
<i class="bi me-1" :class="attachmentIcon(part)"></i>
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,333 +0,0 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../mixins.js';
|
||||
import Prism from "prismjs";
|
||||
import Tags from "bootstrap5-tags";
|
||||
import Attachments from './Attachments.vue';
|
||||
import Headers from './Headers.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
existingTags: Array
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
showTags: false, // to force rerendering of component
|
||||
messageTags: [],
|
||||
allTags: [],
|
||||
loadHeaders: false,
|
||||
showMobileBtns: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
phone: 'width: 322px; height: 570px',
|
||||
tablet: 'width: 768px; height: 1024px',
|
||||
display: 'width: 100%; height: 100%',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
let self = this;
|
||||
self.showTags = 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]");
|
||||
});
|
||||
});
|
||||
},
|
||||
// force eager callback execution
|
||||
immediate: true
|
||||
},
|
||||
messageTags() {
|
||||
// save changed to tags
|
||||
if (this.showTags) {
|
||||
this.saveTags();
|
||||
}
|
||||
},
|
||||
scaleHTMLPreview() {
|
||||
if (this.scaleHTMLPreview == 'display') {
|
||||
let self = this;
|
||||
window.setTimeout(function () {
|
||||
self.resizeIframes();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let self = this;
|
||||
self.showTags = false;
|
||||
self.allTags = self.existingTags;
|
||||
window.addEventListener("resize", self.resizeIframes);
|
||||
self.renderUI();
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab');
|
||||
headersTab.addEventListener('shown.bs.tab', function (event) {
|
||||
self.loadHeaders = true;
|
||||
});
|
||||
|
||||
let rawTab = document.getElementById('nav-raw-tab');
|
||||
rawTab.addEventListener('shown.bs.tab', function (event) {
|
||||
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw';
|
||||
self.resizeIframes();
|
||||
});
|
||||
|
||||
self.showTags = true;
|
||||
self.$nextTick(function () {
|
||||
Tags.init("select[multiple]");
|
||||
});
|
||||
},
|
||||
|
||||
unmounted: function () {
|
||||
window.removeEventListener("resize", this.resizeIframes);
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderUI: function () {
|
||||
let self = this;
|
||||
// click the first non-disabled tab
|
||||
document.querySelector('#nav-tab button:not([disabled])').click();
|
||||
document.activeElement.blur(); // blur focus
|
||||
document.getElementById('message-view').scrollTop = 0;
|
||||
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
window.setTimeout(function () {
|
||||
let p = document.getElementById('preview-html');
|
||||
if (p) {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i];
|
||||
let href = anchorEl.getAttribute('href');
|
||||
|
||||
if (href && href.match(/^http/)) {
|
||||
anchorEl.setAttribute('target', '_blank');
|
||||
}
|
||||
}
|
||||
self.resizeIframes();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// html highlighting
|
||||
window.Prism = window.Prism || {};
|
||||
window.Prism.manual = true;
|
||||
Prism.highlightAll();
|
||||
},
|
||||
|
||||
resizeIframe: function (el) {
|
||||
let i = el.target;
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||
},
|
||||
|
||||
resizeIframes: function () {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return;
|
||||
}
|
||||
let h = document.getElementById('preview-html');
|
||||
if (h) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
saveTags: function () {
|
||||
let self = this;
|
||||
|
||||
var data = {
|
||||
ids: [this.message.ID],
|
||||
tags: this.messageTags
|
||||
}
|
||||
|
||||
self.put('api/v1/tags', data, function (response) {
|
||||
self.scrollInPlace = true;
|
||||
self.$emit('loadMessages');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="mh-100" style="overflow-y: scroll;">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<{{ message.From.Address }}>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span class="text-nowrap">{{ t.Name + " <" + t.Address + ">" }}</span>
|
||||
</span>
|
||||
<span v-else>Undisclosed recipients</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address + ">" }} </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address + ">" }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-muted">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address + ">" }} </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-muted"><{{ message.ReturnPath }}></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''">{{ message.Subject }}</strong>
|
||||
<small class="text-muted" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-md-none small">
|
||||
<th class="small">Date</th>
|
||||
<td>{{ messageDate(message.Date) }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="small" v-if="showTags">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
||||
data-allow-new="true" data-clear-end="true" data-allow-clear="true"
|
||||
data-placeholder="Add tags..." data-badge-style="secondary"
|
||||
data-regex="^([a-zA-Z0-9\-\ \_]){3,}$" 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>
|
||||
</select>
|
||||
<div class="invalid-feedback">Please select a valid tag.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3">
|
||||
<div class="mt-2 mt-md-0" v-if="allAttachments(message)">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2">
|
||||
Attachment<span v-if="allAttachments(message).length > 1">s</span>
|
||||
({{ allAttachments(message).length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
v-on:click="showMobileBtns = true; 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"
|
||||
v-on:click="showMobileBtns = false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
<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' : ''" v-on:click="showMobileBtns = false">Text</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false"
|
||||
v-on:click="showMobileBtns = false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false"
|
||||
v-on:click="showMobileBtns = false">Raw</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-2" v-if="showMobileBtns">
|
||||
<template v-for="vals, key in responsiveSizes">
|
||||
<button class="btn" :class="scaleHTMLPreview == key ? 'btn-outline-primary' : ''"
|
||||
:disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content mb-5" id="nav-tabContent">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="message.HTML"
|
||||
v-on:load="resizeIframe" seamless frameborder="0" style="width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view">{{ message.Text }}</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe" seamless frameborder="0"
|
||||
style="width: 100%; height: 300px;" id="message-src"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
|
||||
<script>
|
||||
import Tags from "bootstrap5-tags";
|
||||
import commonMixins from '../mixins.js';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
relayConfig: 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-muted">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" readonly class="form-control-plaintext" :value="message.From.Address">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class=" col-sm-2 col-form-label text-muted">Subject</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" readonly class="form-control-plaintext" :value="message.Subject">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label text-muted">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">
|
||||
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="relayConfig.MessageRelay.ReturnPath != ''">{{ relayConfig.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>
|
||||
@@ -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-muted 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>
|
||||
@@ -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-muted">
|
||||
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
|
||||
<template v-else>[ no subject ]</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
94
server/ui-src/views/MailboxView.vue
Normal file
94
server/ui-src/views/MailboxView.vue
Normal 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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavMailbox @loadMessages="loadMessages" modals />
|
||||
<AboutMailpit modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
346
server/ui-src/views/MessageView.vue
Normal file
346
server/ui-src/views/MessageView.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Message from '../components/message/Message.vue'
|
||||
import Release from '../components/message/Release.vue'
|
||||
import Screenshot from '../components/message/Screenshot.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
Message,
|
||||
Screenshot,
|
||||
Release,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
message: false,
|
||||
prevLink: false,
|
||||
nextLink: false,
|
||||
errorMessage: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMessage()
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadMessage()
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessage: function () {
|
||||
let self = this
|
||||
this.message = false
|
||||
let uri = self.resolve('/api/v1/message/' + this.$route.params.id)
|
||||
self.get(uri, false, function (response) {
|
||||
self.errorMessage = false
|
||||
|
||||
let d = response.data
|
||||
|
||||
if (self.wasUnread(d.ID)) {
|
||||
mailbox.unread--
|
||||
}
|
||||
|
||||
// replace inline images embedded as inline attachments
|
||||
if (d.HTML && d.Inline) {
|
||||
for (let i in d.Inline) {
|
||||
let a = d.Inline[i]
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
}
|
||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
||||
// some old email clients use the filename
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replace inline images embedded as regular attachments
|
||||
if (d.HTML && d.Attachments) {
|
||||
for (let i in d.Attachments) {
|
||||
let a = d.Attachments[i]
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
}
|
||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
||||
// some old email clients use the filename
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
|
||||
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message = d
|
||||
|
||||
self.detectPrevNext()
|
||||
},
|
||||
function (error) {
|
||||
self.errorMessage = true
|
||||
if (error.response && error.response.data) {
|
||||
if (error.response.data.Error) {
|
||||
self.errorMessage = error.response.data.Error
|
||||
} else {
|
||||
self.errorMessage = error.response.data
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
self.errorMessage = 'Error sending data to the server. Please refresh the page.'
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
self.errorMessage = error.message
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// try detect whether this message was unread based on messages listing
|
||||
wasUnread: function (id) {
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == id) {
|
||||
if (!mailbox.messages[m].Read) {
|
||||
mailbox.messages[m].Read = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
detectPrevNext: function () {
|
||||
// generate the prev/next links based on current message list
|
||||
this.prevLink = false
|
||||
this.nextLink = false
|
||||
let found = false
|
||||
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == this.message.ID) {
|
||||
found = true
|
||||
} else if (found && !this.nextLink) {
|
||||
this.nextLink = mailbox.messages[m].ID
|
||||
break
|
||||
} else {
|
||||
this.prevLink = mailbox.messages[m].ID
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
downloadMessageBody: function (str, ext) {
|
||||
let dl = document.createElement('a')
|
||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
||||
dl.target = '_blank'
|
||||
dl.download = this.message.ID + '.' + ext
|
||||
dl.click()
|
||||
},
|
||||
|
||||
screenshotMessageHTML: function () {
|
||||
this.$refs.ScreenshotRef.initScreenshot()
|
||||
},
|
||||
|
||||
// mark current message as read
|
||||
markUnread: function () {
|
||||
let self = this
|
||||
if (!self.message) {
|
||||
return false
|
||||
}
|
||||
let uri = self.resolve('/api/v1/messages')
|
||||
self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) {
|
||||
self.goBack()
|
||||
})
|
||||
},
|
||||
|
||||
deleteMessage: function () {
|
||||
let self = this
|
||||
let ids = [self.message.ID]
|
||||
let uri = self.resolve('/api/v1/messages')
|
||||
self.delete(uri, { 'ids': ids }, function () {
|
||||
self.goBack()
|
||||
})
|
||||
},
|
||||
|
||||
goBack: function () {
|
||||
mailbox.lastMessage = this.$route.params.id
|
||||
|
||||
if (mailbox.searching) {
|
||||
this.$router.push('/search?q=' + encodeURIComponent(mailbox.searching))
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
|
||||
initReleaseModal: function () {
|
||||
let self = this
|
||||
self.modal('ReleaseModal').show()
|
||||
window.setTimeout(function () {
|
||||
window.setTimeout(function () {
|
||||
// delay to allow elements to load / focus
|
||||
self.$refs.ReleaseRef.initTags()
|
||||
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
|
||||
}, 500)
|
||||
}, 300)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
<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" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
||||
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled" v-on:click="initReleaseModal">
|
||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto col-lg-4 col-xl-4 text-end" v-if="!errorMessage">
|
||||
<div class="dropdown d-inline-block" id="DownloadBtn">
|
||||
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-file-arrow-down-fill"></i>
|
||||
<span class="d-none d-md-inline ms-1">Download</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item"
|
||||
title="Message source including headers, body and attachments">
|
||||
Raw message
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="message.HTML">
|
||||
<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item">
|
||||
HTML body
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="message.HTML">
|
||||
<button class="dropdown-item" @click="screenshotMessageHTML()">
|
||||
HTML screenshot
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="message.Text">
|
||||
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
|
||||
Text body
|
||||
</button>
|
||||
</li>
|
||||
<template v-if="allAttachments(message).length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Attachment<template v-if="allAttachments(message).length > 1">s</template>
|
||||
</h6>
|
||||
</li>
|
||||
<li v-for="part in allAttachments(message)">
|
||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<div class="col-auto p-0 pe-1">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="prevLink ? '' : 'disabled'" title="View previous message">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'">
|
||||
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
||||
</RouterLink>
|
||||
</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;">
|
||||
|
||||
<div class="list-group my-2">
|
||||
<button @click="goBack()" class="list-group-item list-group-item-action">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Return</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread && !errorMessage">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" v-if="!errorMessage">
|
||||
<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>
|
||||
<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">
|
||||
<template v-if="errorMessage">
|
||||
<h3 class="text-center my-3">
|
||||
{{ errorMessage }}
|
||||
</h3>
|
||||
</template>
|
||||
<Message v-else-if="message" :key="message.ID" :message="message" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AboutMailpit modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
<Release v-if="message" ref="ReleaseRef" :message="message" />
|
||||
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
|
||||
</template>
|
||||
29
server/ui-src/views/NotFoundView.vue
Normal file
29
server/ui-src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
|
||||
<div class="d-block text-center">
|
||||
<RouterLink to="/" class="text-white">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;">
|
||||
<p class="h2 my-3">Page not found</p>
|
||||
|
||||
<p>Click here to continue</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="d-none">
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
122
server/ui-src/views/SearchView.vue
Normal file
122
server/ui-src/views/SearchView.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import AboutMailpit from '../components/AboutMailpit.vue'
|
||||
import AjaxLoader from '../components/AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import ListMessages from '../components/ListMessages.vue'
|
||||
import MessagesMixins from '../mixins/MessagesMixins'
|
||||
import NavSearch from '../components/NavSearch.vue'
|
||||
import NavTags from '../components/NavTags.vue'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import SearchForm from '../components/SearchForm.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
ListMessages,
|
||||
NavSearch,
|
||||
NavTags,
|
||||
Pagination,
|
||||
SearchForm,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.doSearch(true)
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mailbox.searching = this.getSearch()
|
||||
this.doSearch(false)
|
||||
},
|
||||
|
||||
methods: {
|
||||
doSearch: function (resetPagination) {
|
||||
let s = this.getSearch()
|
||||
|
||||
if (!s) {
|
||||
mailbox.searching = false
|
||||
this.$router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
mailbox.searching = s
|
||||
|
||||
if (resetPagination) {
|
||||
pagination.start = 0
|
||||
}
|
||||
|
||||
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
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="pagination.start = 0">
|
||||
<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-lg-0">
|
||||
<div class="float-start d-md-none">
|
||||
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
|
||||
data-bs-target="#offcanvas" aria-controls="offcanvas">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
|
||||
</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">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
<div class="col-lg-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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavSearch @loadMessages="loadMessages" modals />
|
||||
<AboutMailpit modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
@@ -66,7 +66,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -103,7 +103,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -122,6 +122,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/html-check": {
|
||||
"get": {
|
||||
"description": "Returns the summary of the message HTML checker.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "HTML check (beta)",
|
||||
"operationId": "HTMLCheckResponse",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTMLCheckResponse",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/HTMLCheckResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/link-check": {
|
||||
"get": {
|
||||
"description": "Returns the summary of the message Link checker.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "Link check (beta)",
|
||||
"operationId": "LinkCheckResponse",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Follow redirects",
|
||||
"name": "follow",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "LinkCheckResponse",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LinkCheckResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/part/{PartID}": {
|
||||
"get": {
|
||||
"description": "This will return the attachment part using the appropriate Content-Type.",
|
||||
@@ -142,14 +223,14 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "attachment part id",
|
||||
"description": "Attachment part ID",
|
||||
"name": "PartID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -183,14 +264,14 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "attachment part id",
|
||||
"description": "Attachment part ID",
|
||||
"name": "PartID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -224,7 +305,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -242,7 +323,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/release": {
|
||||
"post": {
|
||||
"description": "Release a message via a preconfigured external SMTP server..",
|
||||
"description": "Release a message via a pre-configured external SMTP server..",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -261,14 +342,14 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "message id",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Array of email addresses to release message to",
|
||||
"name": "To",
|
||||
"name": "to",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
@@ -307,14 +388,14 @@
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "pagination offset",
|
||||
"description": "Pagination offset",
|
||||
"name": "start",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "limit results",
|
||||
"description": "Limit results",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -347,11 +428,11 @@
|
||||
"operationId": "SetReadStatus",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Message ids to update",
|
||||
"description": "Database IDs to update",
|
||||
"name": "ids",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"description": "Message ids to update",
|
||||
"description": "Database IDs to update",
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/SetReadStatusRequest"
|
||||
}
|
||||
@@ -385,11 +466,11 @@
|
||||
"operationId": "Delete",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Message ids to delete",
|
||||
"description": "Database IDs to delete",
|
||||
"name": "ids",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"description": "Message ids to delete",
|
||||
"description": "Database IDs to delete",
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/DeleteRequest"
|
||||
}
|
||||
@@ -423,15 +504,22 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "search query",
|
||||
"description": "Search query",
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Pagination offset",
|
||||
"name": "start",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "limit results",
|
||||
"description": "Limit results",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -444,9 +532,64 @@
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes messages matching a search.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"messages"
|
||||
],
|
||||
"summary": "Delete messages by search",
|
||||
"operationId": "MessagesSummary",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/OKResponse"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/tags": {
|
||||
"get": {
|
||||
"description": "Returns a JSON array of all unique message tags.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"tags"
|
||||
],
|
||||
"summary": "Get all current tags",
|
||||
"operationId": "SetTags",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ArrayResponse"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "To remove all tags from a message, pass an empty tags array.",
|
||||
"consumes": [
|
||||
@@ -466,12 +609,12 @@
|
||||
"operationId": "SetTags",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Message ids to update",
|
||||
"description": "Database IDs to update",
|
||||
"name": "ids",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "Message ids to update",
|
||||
"description": "Database IDs to update",
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/SetTagsRequest"
|
||||
}
|
||||
@@ -489,7 +632,7 @@
|
||||
},
|
||||
"/api/v1/webui": {
|
||||
"get": {
|
||||
"description": "Returns configuration settings for the web UI.",
|
||||
"description": "Returns configuration settings for the web UI.\nIntended for web UI only!",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -568,23 +711,23 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ContentID": {
|
||||
"description": "content id",
|
||||
"description": "Content ID",
|
||||
"type": "string"
|
||||
},
|
||||
"ContentType": {
|
||||
"description": "content type",
|
||||
"description": "Content type",
|
||||
"type": "string"
|
||||
},
|
||||
"FileName": {
|
||||
"description": "file name",
|
||||
"description": "File name",
|
||||
"type": "string"
|
||||
},
|
||||
"PartID": {
|
||||
"description": "attachment part id",
|
||||
"description": "Attachment part ID",
|
||||
"type": "string"
|
||||
},
|
||||
"Size": {
|
||||
"description": "size in bytes",
|
||||
"description": "Size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
@@ -607,6 +750,223 @@
|
||||
"x-go-name": "deleteRequest",
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"HTMLCheckResponse": {
|
||||
"description": "Response represents the HTML check response struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Platforms": {
|
||||
"description": "All platforms tested, mainly for the web UI",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Total": {
|
||||
"$ref": "#/definitions/HTMLCheckTotal"
|
||||
},
|
||||
"Warnings": {
|
||||
"description": "List of warnings from tests",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/HTMLCheckWarning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-go-name": "Response",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/htmlcheck"
|
||||
},
|
||||
"HTMLCheckResult": {
|
||||
"description": "Result struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Family": {
|
||||
"description": "Family eg: Outlook, Mozilla Thunderbird",
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Friendly name of result, combining family, platform \u0026 version",
|
||||
"type": "string"
|
||||
},
|
||||
"NoteNumber": {
|
||||
"description": "Note number for partially supported if applicable",
|
||||
"type": "string"
|
||||
},
|
||||
"Platform": {
|
||||
"description": "Platform eg: ios, android, windows",
|
||||
"type": "string"
|
||||
},
|
||||
"Support": {
|
||||
"description": "Support [yes, no, partial]",
|
||||
"type": "string"
|
||||
},
|
||||
"Version": {
|
||||
"description": "Family version eg: 4.7.1, 2019-10, 10.3",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Result",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/htmlcheck"
|
||||
},
|
||||
"HTMLCheckScore": {
|
||||
"description": "Score struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Found": {
|
||||
"description": "Number of matches in the document",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Partial": {
|
||||
"description": "Total percentage partially supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Supported": {
|
||||
"description": "Total percentage supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Unsupported": {
|
||||
"description": "Total percentage unsupported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Score",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/htmlcheck"
|
||||
},
|
||||
"HTMLCheckTotal": {
|
||||
"description": "Total weighted result for all scores",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Nodes": {
|
||||
"description": "Total number of HTML nodes detected in message",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Partial": {
|
||||
"description": "Overall percentage partially supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Supported": {
|
||||
"description": "Overall percentage supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Tests": {
|
||||
"description": "Total number of tests done",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Unsupported": {
|
||||
"description": "Overall percentage unsupported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Total",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/htmlcheck"
|
||||
},
|
||||
"HTMLCheckWarning": {
|
||||
"description": "Warning represents a failed test",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Category": {
|
||||
"description": "Category [css, html]",
|
||||
"type": "string"
|
||||
},
|
||||
"Description": {
|
||||
"description": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"Keywords": {
|
||||
"description": "Keywords",
|
||||
"type": "string"
|
||||
},
|
||||
"NotesByNumber": {
|
||||
"description": "Notes based on results",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Results": {
|
||||
"description": "Test results",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/HTMLCheckResult"
|
||||
}
|
||||
},
|
||||
"Score": {
|
||||
"$ref": "#/definitions/HTMLCheckScore"
|
||||
},
|
||||
"Slug": {
|
||||
"description": "Slug identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"Tags": {
|
||||
"description": "Tags",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Title": {
|
||||
"description": "Friendly title",
|
||||
"type": "string"
|
||||
},
|
||||
"URL": {
|
||||
"description": "URL to caniemail.com",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Warning",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/htmlcheck"
|
||||
},
|
||||
"Link": {
|
||||
"description": "Link struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Status": {
|
||||
"description": "HTTP status definition",
|
||||
"type": "string"
|
||||
},
|
||||
"StatusCode": {
|
||||
"description": "HTTP status code",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"URL": {
|
||||
"description": "Link URL",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/linkcheck"
|
||||
},
|
||||
"LinkCheckResponse": {
|
||||
"description": "Response represents the Link check response",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Errors": {
|
||||
"description": "Total number of errors",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Links": {
|
||||
"description": "Tested links",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Link"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-go-name": "Response",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/linkcheck"
|
||||
},
|
||||
"Message": {
|
||||
"description": "Message data excluding physical attachments",
|
||||
"type": "object",
|
||||
@@ -645,7 +1005,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ID": {
|
||||
"description": "Unique message database id",
|
||||
"description": "Database ID",
|
||||
"type": "string"
|
||||
},
|
||||
"Inline": {
|
||||
@@ -655,9 +1015,9 @@
|
||||
"$ref": "#/definitions/Attachment"
|
||||
}
|
||||
},
|
||||
"Read": {
|
||||
"description": "Read status",
|
||||
"type": "boolean"
|
||||
"MessageID": {
|
||||
"description": "Message ID",
|
||||
"type": "string"
|
||||
},
|
||||
"ReplyTo": {
|
||||
"description": "ReplyTo addresses",
|
||||
@@ -667,7 +1027,7 @@
|
||||
}
|
||||
},
|
||||
"ReturnPath": {
|
||||
"description": "ReturnPath is the Return-Path",
|
||||
"description": "Return-Path",
|
||||
"type": "string"
|
||||
},
|
||||
"Size": {
|
||||
@@ -744,7 +1104,11 @@
|
||||
"$ref": "#/definitions/Address"
|
||||
},
|
||||
"ID": {
|
||||
"description": "Unique message database id",
|
||||
"description": "Database ID",
|
||||
"type": "string"
|
||||
},
|
||||
"MessageID": {
|
||||
"description": "Message ID",
|
||||
"type": "string"
|
||||
},
|
||||
"Read": {
|
||||
@@ -781,12 +1145,6 @@
|
||||
"description": "MessagesSummary is a summary of a list of messages",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "Number of results returned",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "Count"
|
||||
},
|
||||
"messages": {
|
||||
"description": "Messages summary\nin:body",
|
||||
"type": "array",
|
||||
@@ -795,6 +1153,12 @@
|
||||
},
|
||||
"x-go-name": "Messages"
|
||||
},
|
||||
"messages_count": {
|
||||
"description": "Total number of messages matching current query",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "MessagesCount"
|
||||
},
|
||||
"start": {
|
||||
"description": "Pagination offset",
|
||||
"type": "integer",
|
||||
@@ -889,6 +1253,10 @@
|
||||
"description": "Response includes global web UI settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DisableHTMLCheck": {
|
||||
"description": "Whether the HTML check has been globally disabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"MessageRelay": {
|
||||
"description": "Message Relay information",
|
||||
"type": "object",
|
||||
@@ -897,6 +1265,10 @@
|
||||
"description": "Whether message relaying (release) is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"RecipientAllowlist": {
|
||||
"description": "Allowlist of accepted recipients",
|
||||
"type": "string"
|
||||
},
|
||||
"ReturnPath": {
|
||||
"description": "Enforced Return-Path (if set) for relay bounces",
|
||||
"type": "string"
|
||||
@@ -913,11 +1285,20 @@
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"ArrayResponse": {
|
||||
"description": "Plain JSON array response",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BinaryResponse": {
|
||||
"description": "Binary data reponse inherits the attachment's content type"
|
||||
"description": "Binary data response inherits the attachment's content type"
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"description": "Error reponse"
|
||||
"description": "Error response"
|
||||
},
|
||||
"InfoResponse": {
|
||||
"description": "Application information",
|
||||
@@ -937,7 +1318,7 @@
|
||||
}
|
||||
},
|
||||
"OKResponse": {
|
||||
"description": "Plain text \"ok\" reponse"
|
||||
"description": "Plain text \"ok\" response"
|
||||
},
|
||||
"TextResponse": {
|
||||
"description": "Plain text response"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<!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="favicon.svg">
|
||||
<title>Mailpit</title>
|
||||
<link rel=stylesheet href="dist/app.css">
|
||||
</head>
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app">
|
||||
<noscript>You require JavaScript to use this app.</noscript>
|
||||
</div>
|
||||
|
||||
<script src="dist/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -79,7 +79,7 @@ func Broadcast(t string, msg interface{}) {
|
||||
b, err := json.Marshal(w)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[http] broadcast received invalid data: %s", err)
|
||||
logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err)
|
||||
}
|
||||
|
||||
go func() { MessageHub.Broadcast <- b }()
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -26,7 +25,6 @@ import (
|
||||
"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
|
||||
@@ -72,20 +70,51 @@ var (
|
||||
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 {
|
||||
Created time.Time
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Size int
|
||||
Inline int
|
||||
Attachments int
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
}
|
||||
|
||||
// InitDB will initialise the database
|
||||
@@ -144,6 +173,8 @@ func InitDB() error {
|
||||
// auto-prune & delete
|
||||
go dbCron()
|
||||
|
||||
go dataMigrations()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,9 +203,10 @@ 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.
|
||||
// Parse message body with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
logger.Log().Warningf("[db] %s", err.Error())
|
||||
@@ -189,22 +221,21 @@ func Store(body []byte) (string, error) {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
|
||||
obj := DBMailSummary{
|
||||
Created: time.Now(),
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Size: len(body),
|
||||
Inline: len(env.Inlines),
|
||||
Attachments: len(env.Attachments),
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
}
|
||||
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
if config.UseMessageDates {
|
||||
if mDate, err := env.Date(); err == nil {
|
||||
obj.Created = mDate
|
||||
created = mDate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +250,16 @@ func Store(body []byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tagData := findTags(&body)
|
||||
// extract tags from body matches based on --tag
|
||||
tagStr := findTagsInRawMessage(&body)
|
||||
|
||||
// extract tags from X-Tags header
|
||||
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
if headerTags != "" {
|
||||
tagStr += "," + headerTags
|
||||
}
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
tagJSON, err := json.Marshal(tagData)
|
||||
if err != nil {
|
||||
@@ -237,8 +277,14 @@ func Store(body []byte) (string, error) {
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := len(body)
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
|
||||
// insert mail summary data
|
||||
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Tags, Read) values(?,?,?,?,0)", id, string(summaryJSON), searchText, string(tagJSON))
|
||||
_, 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))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -259,14 +305,20 @@ func Store(body []byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
c.Tags = tagData
|
||||
|
||||
c.Created = created
|
||||
c.ID = id
|
||||
c.MessageID = messageID
|
||||
c.Attachments = attachments
|
||||
c.Subject = subject
|
||||
c.Size = size
|
||||
c.Tags = tagData
|
||||
|
||||
websockets.Broadcast("new", c)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -276,24 +328,29 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`ID, Data, Tags, Read`).
|
||||
OrderBy("Sort DESC").
|
||||
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
|
||||
OrderBy("Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var summary string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&id, &summary, &tags, &read); err != nil {
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(summary), &em); err != nil {
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
@@ -303,11 +360,15 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
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
|
||||
|
||||
results = append(results, em)
|
||||
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
@@ -317,70 +378,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 interprits 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, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
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, errors.New("Your search contains invalid characters")
|
||||
}
|
||||
|
||||
// generate the SQL based on arguments
|
||||
q := searchParser(args, start, limit)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var summary string
|
||||
var tags string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&id, &summary, &tags, &read, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(summary), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
em.ID = id
|
||||
em.Read = read == 1
|
||||
|
||||
results = append(results, em)
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return results, 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) {
|
||||
@@ -404,8 +401,10 @@ func GetMessage(id string) (*Message, error) {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
|
||||
if returnPath == "" {
|
||||
if returnPath == "" && from != nil {
|
||||
returnPath = from.Address
|
||||
}
|
||||
|
||||
@@ -413,27 +412,20 @@ func GetMessage(id string) (*Message, error) {
|
||||
if err != nil {
|
||||
// return received datetime when message does not contain a date header
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Data`).
|
||||
OrderBy("Sort DESC").
|
||||
Select(`Created`).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var summary string
|
||||
em := MessageSummary{}
|
||||
var created int64
|
||||
|
||||
if err := row.Scan(&summary); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(summary), &em); err != nil {
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = em.Created
|
||||
date = time.UnixMilli(created)
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
@@ -441,7 +433,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
|
||||
obj := Message{
|
||||
ID: id,
|
||||
Read: true,
|
||||
MessageID: messageID,
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
@@ -455,10 +447,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
Text: env.Text,
|
||||
}
|
||||
|
||||
// strip base tags
|
||||
var re = regexp.MustCompile(`(?U)<base .*>`)
|
||||
html := re.ReplaceAllString(env.HTML, "")
|
||||
obj.HTML = html
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
|
||||
@@ -570,6 +559,8 @@ func MarkRead(id string) error {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -591,6 +582,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
|
||||
@@ -614,6 +607,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
|
||||
@@ -636,6 +631,8 @@ func MarkUnread(id string) error {
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -670,6 +667,8 @@ func DeleteOneMessage(id string) error {
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -718,19 +717,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 != ?", "[]")
|
||||
@@ -763,6 +756,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,
|
||||
@@ -821,3 +827,16 @@ func IsUnread(id string) bool {
|
||||
|
||||
return unread == 1
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("MessageID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
|
||||
return total != 0
|
||||
}
|
||||
|
||||
@@ -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,76 +117,6 @@ 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) {
|
||||
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
|
||||
searchIndx := rand.Intn(4) + 1
|
||||
var search string
|
||||
switch searchIndx {
|
||||
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 rsults
|
||||
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 BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
defer Close()
|
||||
@@ -229,44 +141,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.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
|
||||
}
|
||||
}
|
||||
|
||||
200
storage/migrationTasks.go
Normal file
200
storage/migrationTasks.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/leporo/sqlf"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
func dataMigrations() {
|
||||
updateOrderByCreatedTask()
|
||||
assignMessageIDsTask()
|
||||
}
|
||||
|
||||
// Update Created column using Created metadata datetime <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func updateOrderByCreatedTask() {
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Select(`json_extract(Metadata, '$.Created') as Created`).
|
||||
Where("Created < ?", 1155000600)
|
||||
|
||||
toUpdate := make(map[string]int64)
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var ts sql.NullString
|
||||
if err := row.Scan(&id, &ts); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ts.Valid {
|
||||
logger.Log().Errorf("[migration] cannot get Created timestamp from %s", id)
|
||||
return
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339Nano, ts.String)
|
||||
toUpdate[id] = t.UnixMilli()
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
total := len(toUpdate)
|
||||
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] updating timestamp for %s messages", p.Sprintf("%d", len(toUpdate)))
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
var blockTime = time.Now()
|
||||
|
||||
count := 0
|
||||
for id, ts := range toUpdate {
|
||||
count++
|
||||
_, err := tx.Exec(`UPDATE mailbox SET Created = ? WHERE ID = ?`, ts, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] updated timestamp for 1,000 messages [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
|
||||
// Find any messages without a stored Message-ID and update it <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func assignMessageIDsTask() {
|
||||
if !config.IgnoreDuplicateIDs {
|
||||
return
|
||||
}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Where("MessageID = ''")
|
||||
|
||||
missingIDS := make(map[string]string)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
if err := row.Scan(&id); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
missingIDS[id] = ""
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if len(missingIDS) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
var blockTime = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
total := len(missingIDS)
|
||||
|
||||
logger.Log().Infof("[migration] extracting Message-IDs for %s messages", p.Sprintf("%d", total))
|
||||
|
||||
for id := range missingIDS {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
missingIDS[id] = messageID
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] extracted 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
count = 0
|
||||
|
||||
for id, mid := range missingIDS {
|
||||
_, err = tx.Exec(`UPDATE mailbox SET MessageID = ? WHERE ID = ?`, mid, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] stored 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
35
storage/notifications.go
Normal file
35
storage/notifications.go
Normal 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)
|
||||
}()
|
||||
}
|
||||
@@ -1,35 +1,205 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
||||
if limit == 0 {
|
||||
// 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 := sqlf.From("mailbox").
|
||||
Select(`ID, Data, Tags, Read,
|
||||
json_extract(Data, '$.To') as ToJSON,
|
||||
json_extract(Data, '$.From') as FromJSON,
|
||||
IFNULL(json_extract(Data, '$.Cc'), '{}') as CcJSON,
|
||||
IFNULL(json_extract(Data, '$.Bcc'), '{}') as BccJSON,
|
||||
json_extract(Data, '$.Subject') as Subject,
|
||||
json_extract(Data, '$.Attachments') as Attachments
|
||||
`).
|
||||
OrderBy("Sort DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
q := searchParser(search)
|
||||
var err error
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 := searchParser(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 ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &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 searchParser(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,
|
||||
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
|
||||
@@ -84,7 +254,7 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "subject:") {
|
||||
w = cleanString(w[8:])
|
||||
w = w[8:]
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
@@ -92,6 +262,15 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
||||
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 != "" {
|
||||
@@ -113,6 +292,12 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
||||
} 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")
|
||||
@@ -122,9 +307,9 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
} else {
|
||||
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
storage/search_test.go
Normal file
152
storage/search_test.go
Normal 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")
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
//
|
||||
// swagger:model Message
|
||||
type Message struct {
|
||||
// Unique message database id
|
||||
// Database ID
|
||||
ID string
|
||||
// Read status
|
||||
Read bool
|
||||
// Message ID
|
||||
MessageID string
|
||||
// From address
|
||||
From *mail.Address
|
||||
// To addresses
|
||||
@@ -25,7 +25,7 @@ type Message struct {
|
||||
Bcc []*mail.Address
|
||||
// ReplyTo addresses
|
||||
ReplyTo []*mail.Address
|
||||
// ReturnPath is the Return-Path
|
||||
// Return-Path
|
||||
ReturnPath string
|
||||
// Message subject
|
||||
Subject string
|
||||
@@ -49,15 +49,15 @@ type Message struct {
|
||||
//
|
||||
// swagger:model Attachment
|
||||
type Attachment struct {
|
||||
// attachment part id
|
||||
// Attachment part ID
|
||||
PartID string
|
||||
// file name
|
||||
// File name
|
||||
FileName string
|
||||
// content type
|
||||
// Content type
|
||||
ContentType string
|
||||
// content id
|
||||
// Content ID
|
||||
ContentID string
|
||||
// size in bytes
|
||||
// Size in bytes
|
||||
Size int
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@ type Attachment struct {
|
||||
//
|
||||
// swagger:model MessageSummary
|
||||
type MessageSummary struct {
|
||||
// Unique message database id
|
||||
// Database ID
|
||||
ID string
|
||||
// Message ID
|
||||
MessageID string
|
||||
// Read status
|
||||
Read bool
|
||||
// From address
|
||||
|
||||
@@ -3,27 +3,27 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetTags will set the tags for a given message ID, used via API
|
||||
// SetTags will set the tags for a given database ID, used via API
|
||||
func SetTags(id string, tags []string) error {
|
||||
applyTags := []string{}
|
||||
reg := regexp.MustCompile(`\s+`)
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(reg.ReplaceAllString(t, " "))
|
||||
|
||||
if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(applyTags)
|
||||
|
||||
tagJSON, err := json.Marshal(applyTags)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] setting tags for message %s", id)
|
||||
@@ -42,26 +42,25 @@ func SetTags(id string, tags []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Used to auto-apply tags to new messages
|
||||
func findTags(message *[]byte) []string {
|
||||
tags := []string{}
|
||||
// Find tags set via --tags in raw message.
|
||||
// Returns a comma-separated string.
|
||||
func findTagsInRawMessage(message *[]byte) string {
|
||||
tagStr := ""
|
||||
if len(config.SMTPTags) == 0 {
|
||||
return tags
|
||||
return tagStr
|
||||
}
|
||||
|
||||
str := strings.ToLower(string(*message))
|
||||
for _, t := range config.SMTPTags {
|
||||
if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) {
|
||||
tags = append(tags, t.Tag)
|
||||
if strings.Contains(str, t.Match) {
|
||||
tagStr += "," + t.Tag
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
return tagStr
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given message ID.
|
||||
// Get message tags from the database for a given database ID
|
||||
// Used when parsing a raw email.
|
||||
func getMessageTags(id string) []string {
|
||||
tags := []string{}
|
||||
@@ -84,3 +83,31 @@ func getMessageTags(id string) []string {
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
|
||||
func uniqueTagsFromString(s string) []string {
|
||||
tags := []string{}
|
||||
|
||||
if s == "" {
|
||||
return tags
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
w := tools.CleanTag(p)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if config.ValidTagRegexp.MatchString(w) {
|
||||
if !inArray(w, tags) {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
43
storage/tags_test.go
Normal file
43
storage/tags_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
57
storage/test_shared.go
Normal file
57
storage/test_shared.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/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)
|
||||
}
|
||||
}
|
||||
20
storage/testdata/mime-attachment.eml
vendored
20
storage/testdata/mime-attachment.eml
vendored
@@ -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.
|
||||
|
||||
@@ -37,9 +37,11 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
b.WriteString(env.GetHeader("To") + " ")
|
||||
b.WriteString(env.GetHeader("Cc") + " ")
|
||||
b.WriteString(env.GetHeader("Bcc") + " ")
|
||||
b.WriteString(env.GetHeader("Reply-To") + " ")
|
||||
b.WriteString(env.GetHeader("Return-Path") + " ")
|
||||
h := strings.TrimSpace(
|
||||
html2text.HTML2TextWithOptions(
|
||||
env.HTML,
|
||||
env.HTML,
|
||||
html2text.WithLinksInnerText(),
|
||||
),
|
||||
)
|
||||
@@ -61,7 +63,7 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
// CleanString removes unwanted characters from stored search text and search queries
|
||||
func cleanString(str string) string {
|
||||
// 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
|
||||
@@ -75,7 +77,7 @@ func dbCron() {
|
||||
time.Sleep(60 * time.Second)
|
||||
start := time.Now()
|
||||
|
||||
// check if database contains deleted data and has not beein in use
|
||||
// check if database contains deleted data and has not been in use
|
||||
// for 5 minutes, if so VACUUM
|
||||
currentTime := time.Now()
|
||||
diff := currentTime.Sub(dbLastAction)
|
||||
@@ -92,7 +94,7 @@ func dbCron() {
|
||||
if config.MaxMessages > 0 {
|
||||
q := sqlf.Select("ID").
|
||||
From("mailbox").
|
||||
OrderBy("Sort DESC").
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
@@ -167,6 +169,7 @@ func isFile(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
@@ -182,3 +185,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)
|
||||
}
|
||||
|
||||
5
utils/htmlcheck/README.md
Normal file
5
utils/htmlcheck/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# HTML check
|
||||
|
||||
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
|
||||
|
||||
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)
|
||||
4150
utils/htmlcheck/caniemail-data.json
Normal file
4150
utils/htmlcheck/caniemail-data.json
Normal file
File diff suppressed because it is too large
Load Diff
74
utils/htmlcheck/caniemail.go
Normal file
74
utils/htmlcheck/caniemail.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package htmlcheck is used for parsing HTML and returning
|
||||
// HTML compatibility errors and warnings
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
//go:embed caniemail-data.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
var (
|
||||
cie = CanIEmail{}
|
||||
|
||||
noteMatch = regexp.MustCompile(` #(\d)+$`)
|
||||
|
||||
// LimitFamilies will limit results to families if set
|
||||
LimitFamilies = []string{}
|
||||
|
||||
// LimitPlatforms will limit results to platforms if set
|
||||
LimitPlatforms = []string{}
|
||||
|
||||
// LimitClients will limit results to clients if set
|
||||
LimitClients = []string{}
|
||||
)
|
||||
|
||||
// CanIEmail struct for JSON data
|
||||
type CanIEmail struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
LastUpdateDate string `json:"last_update_date"`
|
||||
// NiceNames map[string]string `json:"last_update_date"`
|
||||
NiceNames struct {
|
||||
Family map[string]string `json:"family"`
|
||||
Platform map[string]string `json:"platform"`
|
||||
Support map[string]string `json:"support"`
|
||||
Category map[string]string `json:"category"`
|
||||
} `json:"nicenames"`
|
||||
Data []JSONResult `json:"data"`
|
||||
}
|
||||
|
||||
// JSONResult struct for CanIEmail Data
|
||||
type JSONResult struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
}
|
||||
|
||||
// Load the JSON data
|
||||
func loadJSONData() error {
|
||||
if cie.APIVersion != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := embeddedFS.ReadFile("caniemail-data.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cie = CanIEmail{}
|
||||
|
||||
return json.Unmarshal(b, &cie)
|
||||
}
|
||||
203
utils/htmlcheck/config.go
Normal file
203
utils/htmlcheck/config.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package htmlcheck
|
||||
|
||||
import "regexp"
|
||||
|
||||
// HTML tests
|
||||
var htmlTests = map[string]string{
|
||||
// body check is manually done because it always exists in *goquery.Document
|
||||
"html-body": "body",
|
||||
// HTML tests
|
||||
"html-object": "object, embed, image, pdf",
|
||||
"html-link": "link",
|
||||
"html-dialog": "dialog",
|
||||
"html-srcset": "[srcset]",
|
||||
"html-picture": "picture",
|
||||
"html-svg": "svg",
|
||||
"html-progress": "progress",
|
||||
"html-required": "[required]",
|
||||
"html-meter": "meter",
|
||||
"html-audio": "audio",
|
||||
"html-form": "form",
|
||||
"html-input-submit": "submit",
|
||||
"html-button-reset": "button[type=\"reset\"]",
|
||||
"html-button-submit": "submit, button[type=\"submit\"]",
|
||||
"html-base": "base",
|
||||
"html-input-checkbox": "checkbox",
|
||||
"html-input-hidden": "[type=\"hidden\"]",
|
||||
"html-input-radio": "radio",
|
||||
"html-input-text": "input[type=\"text\"]",
|
||||
"html-video": "video",
|
||||
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
|
||||
"html-select": "select",
|
||||
"html-textarea": "textarea",
|
||||
"html-anchor-links": "a[href^=\"#\"]",
|
||||
"html-style": "style",
|
||||
"html-image-maps": "map, img[usemap]",
|
||||
}
|
||||
|
||||
// Image tests using regex to match against img[src]
|
||||
var imageRegexpTests = map[string]*regexp.Regexp{
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
|
||||
}
|
||||
|
||||
var cssInlineTests = map[string]string{
|
||||
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
|
||||
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
|
||||
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
|
||||
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
|
||||
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
|
||||
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
|
||||
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
|
||||
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
|
||||
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
|
||||
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
|
||||
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
|
||||
"css-background": "[style*=\"background:\"], [background]", // 57.407406
|
||||
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
|
||||
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
|
||||
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
|
||||
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
|
||||
"css-border": "[style*=\"border:\"], [border]", // 86.95652
|
||||
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
|
||||
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
|
||||
"css-caption-side": "[style*=\"caption-side:\"]", // 84
|
||||
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
|
||||
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
|
||||
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
|
||||
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
|
||||
"css-direction": "[style*=\"direction:\"]", // 97.77778
|
||||
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
|
||||
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
|
||||
"css-display-none": "[style*=\"display:none\"]", // 84.78261
|
||||
"css-display": "[style*=\"display:\"]", // 55.555553
|
||||
"css-filter": "[style*=\"filter:\"]", // 50
|
||||
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
|
||||
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
|
||||
"css-float": "[style*=\"float:\"]", // 85.10638
|
||||
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
|
||||
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
|
||||
"css-font": "[style*=\"font:\"]", // 95.833336
|
||||
"css-gap": "[style*=\"gap:\"]", // 40
|
||||
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
|
||||
"css-height": "[style*=\"height:\"], [height]", // 77.08333
|
||||
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
|
||||
"css-important": "[style*=\"!important\"]", // 43.478264
|
||||
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
|
||||
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
|
||||
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
|
||||
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
|
||||
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
|
||||
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
|
||||
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
|
||||
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
|
||||
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
|
||||
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
|
||||
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
|
||||
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
|
||||
"css-margin": "[style*=\"margin:\"]", // 71.42857
|
||||
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
|
||||
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
|
||||
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
|
||||
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
|
||||
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
|
||||
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
|
||||
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
|
||||
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
|
||||
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
|
||||
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
|
||||
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
|
||||
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
|
||||
"css-outline": "[style*=\"outline:\"]", // 80.85106
|
||||
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
|
||||
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
|
||||
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
|
||||
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
|
||||
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
|
||||
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
|
||||
"css-position": "[style*=\"position:\"]", // 19.56522
|
||||
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
|
||||
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
|
||||
"css-rgba": "[style*=\"rgba(\"]", // 56
|
||||
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
|
||||
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
|
||||
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
|
||||
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
|
||||
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
|
||||
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
|
||||
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
|
||||
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
|
||||
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
|
||||
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
|
||||
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
|
||||
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
|
||||
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
|
||||
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
|
||||
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
|
||||
"css-transform": "[style*=\"transform:\"]", // 50
|
||||
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
|
||||
"css-variables": "[style*=\"variables:\"]", // 46.551727
|
||||
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
|
||||
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
|
||||
"css-width": "[style*=\"width:\"], [width]", // 87.5
|
||||
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
|
||||
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
|
||||
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
|
||||
}
|
||||
|
||||
// some CSS tests using regex for things that can't be merged inline
|
||||
var cssRegexpTests = map[string]*regexp.Regexp{
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
|
||||
}
|
||||
|
||||
// some CSS tests using regex for units
|
||||
var cssRegexpUnitTests = map[string]*regexp.Regexp{
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
|
||||
}
|
||||
219
utils/htmlcheck/css.go
Normal file
219
utils/htmlcheck/css.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/vanng822/go-premailer/premailer"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// Go cannot calculate any rendered CSS attributes, so we merge all styles
|
||||
// into the HTML and detect elements with styles containing the keywords.
|
||||
func runCSSTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
inlined, err := inlineRemoteCSS(html)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
inlined = html
|
||||
}
|
||||
|
||||
// merge all CSS inline
|
||||
merged, err := mergeInlineCSS(inlined)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
merged = inlined
|
||||
}
|
||||
|
||||
reader := strings.NewReader(merged)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
for key, test := range cssInlineTests {
|
||||
totalTests++
|
||||
found := len(doc.Find(test).Nodes)
|
||||
if found > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
allNodeStyles := []string{}
|
||||
for _, n := range doc.Find("*[style]").Nodes {
|
||||
style, err := tools.GetHTMLAttributeVal(n, "style")
|
||||
if err == nil {
|
||||
allNodeStyles = append(allNodeStyles, style)
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpUnitTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := 0
|
||||
// loop through all styles to count total
|
||||
for _, styles := range allNodeStyles {
|
||||
found = found + len(re.FindAllString(styles, -1))
|
||||
}
|
||||
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get all inline CSS block data
|
||||
reader = strings.NewReader(inlined)
|
||||
|
||||
// Load the HTML document
|
||||
doc, _ = goquery.NewDocumentFromReader(reader)
|
||||
|
||||
cssCode := ""
|
||||
for _, n := range doc.Find("style").Nodes {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
cssCode = cssCode + c.Data
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := len(re.FindAllString(cssCode, -1))
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
|
||||
// MergeInlineCSS merges header CSS into element attributes
|
||||
func mergeInlineCSS(html string) (string, error) {
|
||||
options := premailer.NewOptions()
|
||||
// options.RemoveClasses = true
|
||||
// options.CssToAttributes = false
|
||||
options.KeepBangImportant = true
|
||||
pre, err := premailer.NewPremailerFromString(html, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pre.Transform()
|
||||
}
|
||||
|
||||
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
|
||||
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
|
||||
func inlineRemoteCSS(h string) (string, error) {
|
||||
reader := strings.NewReader(h)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range remoteCSS {
|
||||
attributes := link.Attr
|
||||
for _, a := range attributes {
|
||||
if a.Key == "href" {
|
||||
if !isURL(a.Val) {
|
||||
// skip invalid URL
|
||||
continue
|
||||
}
|
||||
|
||||
if config.BlockRemoteCSSAndFonts {
|
||||
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warningf("html check failed to download %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
// create new <style> block and insert downloaded CSS
|
||||
styleBlock := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "style",
|
||||
DataAtom: atom.Style,
|
||||
}
|
||||
styleBlock.AppendChild(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: string(resp),
|
||||
})
|
||||
|
||||
link.Parent.AppendChild(styleBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newDoc, err := doc.Html()
|
||||
if err != nil {
|
||||
logger.Log().Warning(err)
|
||||
return h, err
|
||||
}
|
||||
|
||||
return newDoc, nil
|
||||
}
|
||||
|
||||
// DownloadToBytes returns a []byte slice from a URL
|
||||
func downloadToBytes(url string) ([]byte, error) {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
102
utils/htmlcheck/html.go
Normal file
102
utils/htmlcheck/html.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
)
|
||||
|
||||
// HTML tests
|
||||
func runHTMLTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
reader := strings.NewReader(html)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
// Almost all <script> is bad
|
||||
scripts := len(doc.Find("script:not([type=\"application/ld+json\"]):not([type=\"application/json\"])").Nodes)
|
||||
if scripts > 0 {
|
||||
var result = Warning{}
|
||||
result.Title = "<script> element"
|
||||
result.Slug = "html-script"
|
||||
result.Category = "html"
|
||||
result.Description = "JavaScript is not supported in any email client."
|
||||
result.Tags = []string{}
|
||||
result.Results = []Result{}
|
||||
result.NotesByNumber = map[string]string{}
|
||||
result.Score.Found = scripts
|
||||
result.Score.Supported = 0
|
||||
result.Score.Partial = 0
|
||||
result.Score.Unsupported = 100
|
||||
results = append(results, result)
|
||||
totalTests++
|
||||
}
|
||||
|
||||
for key, test := range htmlTests {
|
||||
totalTests++
|
||||
if test == "body" {
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
if re.MatchString(html) {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
result.Score.Found = 1
|
||||
results = append(results, result)
|
||||
}
|
||||
} else if len(doc.Find(test).Nodes) > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
totalTests++
|
||||
|
||||
result.Score.Found = len(doc.Find(test).Nodes)
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// find all images
|
||||
images := doc.Find("img[src]").Nodes
|
||||
imageResults := make(map[string]int)
|
||||
totalTests = totalTests + len(imageRegexpTests)
|
||||
|
||||
for _, image := range images {
|
||||
src, err := tools.GetHTMLAttributeVal(image, "src")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for key, test := range imageRegexpTests {
|
||||
if test.MatchString(src) {
|
||||
matches, exists := imageResults[key]
|
||||
if exists {
|
||||
imageResults[key] = matches + 1
|
||||
} else {
|
||||
imageResults[key] = 1
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, found := range imageResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
212
utils/htmlcheck/main.go
Normal file
212
utils/htmlcheck/main.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(html string) (Response, error) {
|
||||
s := Response{}
|
||||
s.Warnings = []Warning{}
|
||||
if platforms, err := Platforms(); err == nil {
|
||||
s.Platforms = platforms
|
||||
}
|
||||
|
||||
s.Total = Total{}
|
||||
|
||||
// crude way to determine whether the HTML contains a <body> structure
|
||||
// or whether it's just plain HTML content
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
nodeMatch := "body *, script"
|
||||
if re.MatchString(html) {
|
||||
nodeMatch = "*:not(html):not(head):not(meta), script"
|
||||
}
|
||||
reader := strings.NewReader(html)
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
// calculate the number of nodes in HTML
|
||||
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// HTML tests
|
||||
htmlResults, totalTests, err := runHTMLTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add html test totals
|
||||
s.Warnings = append(s.Warnings, htmlResults...)
|
||||
|
||||
// CSS tests
|
||||
cssResults, totalTests, err := runCSSTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add css test totals
|
||||
s.Warnings = append(s.Warnings, cssResults...)
|
||||
|
||||
// calculate total score
|
||||
var partial, unsupported float32
|
||||
partial = 0
|
||||
unsupported = 0
|
||||
|
||||
for _, w := range s.Warnings {
|
||||
if w.Score.Found == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// supported is calculated by subtracting partial and unsupported from 100%
|
||||
if w.Score.Partial > 0 {
|
||||
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > partial {
|
||||
partial = weighted
|
||||
}
|
||||
}
|
||||
if w.Score.Unsupported > 0 {
|
||||
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > unsupported {
|
||||
unsupported = weighted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Total.Supported = 100 - partial - unsupported
|
||||
s.Total.Partial = partial
|
||||
s.Total.Unsupported = unsupported
|
||||
|
||||
// sort slice to get lowest scores first
|
||||
sort.Slice(s.Warnings, func(i, j int) bool {
|
||||
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
|
||||
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Test returns a test
|
||||
func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
warning := Warning{}
|
||||
exists := false
|
||||
found := JSONResult{}
|
||||
for _, r := range cie.Data {
|
||||
if r.Slug == k {
|
||||
found = r
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return warning, fmt.Errorf("%s does not exist", k)
|
||||
}
|
||||
|
||||
warning.Slug = found.Slug
|
||||
warning.Title = found.Title
|
||||
warning.Description = mdToHTML(found.Description)
|
||||
warning.Category = found.Category
|
||||
warning.URL = found.URL
|
||||
warning.Tags = found.Tags
|
||||
// warning.Keywords = found.Keywords
|
||||
// warning.Notes = found.Notes
|
||||
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
|
||||
for nr, note := range found.NotesByNumber {
|
||||
warning.NotesByNumber[nr] = mdToHTML(note)
|
||||
}
|
||||
warning.Results = []Result{}
|
||||
|
||||
var y, n, p float32
|
||||
|
||||
for family, stats := range found.Stats {
|
||||
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
s := Result{}
|
||||
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
|
||||
s.Family = family
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
warning.Results = append(warning.Results, s)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
total := y + n + p
|
||||
warning.Score.Supported = y / total * 100
|
||||
warning.Score.Unsupported = n / total * 100
|
||||
warning.Score.Partial = p / total * 100
|
||||
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func inArray(n string, h []string) bool {
|
||||
n = strings.ToLower(n)
|
||||
|
||||
for _, v := range h {
|
||||
if strings.ToLower(v) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert markdown to HTML, stripping <p> & </p>
|
||||
func mdToHTML(str string) string {
|
||||
md := []byte(str)
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
// extensions := parser.NoExtensions
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse(md)
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
|
||||
}
|
||||
38
utils/htmlcheck/platforms.go
Normal file
38
utils/htmlcheck/platforms.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package htmlcheck
|
||||
|
||||
import "sort"
|
||||
|
||||
// Platforms returns all platforms with their respective email clients
|
||||
func Platforms() (map[string][]string, error) {
|
||||
// [platform]clients
|
||||
data := make(map[string][]string)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
for _, t := range cie.Data {
|
||||
for family, stats := range t.Stats {
|
||||
niceFamily := cie.NiceNames.Family[family]
|
||||
for platform := range stats.(map[string]interface{}) {
|
||||
c, found := data[platform]
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
}
|
||||
if !inArray(niceFamily, c) {
|
||||
c = append(c, niceFamily)
|
||||
data[platform] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group, clients := range data {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i] < clients[j]
|
||||
})
|
||||
data[group] = clients
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
87
utils/htmlcheck/structs.go
Normal file
87
utils/htmlcheck/structs.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package htmlcheck
|
||||
|
||||
// Response represents the HTML check response struct
|
||||
//
|
||||
// swagger:model HTMLCheckResponse
|
||||
type Response struct {
|
||||
// List of warnings from tests
|
||||
Warnings []Warning `json:"Warnings"`
|
||||
// All platforms tested, mainly for the web UI
|
||||
Platforms map[string][]string `json:"Platforms"`
|
||||
// Total overall score
|
||||
Total Total `json:"Total"`
|
||||
}
|
||||
|
||||
// Warning represents a failed test
|
||||
//
|
||||
// swagger:model HTMLCheckWarning
|
||||
type Warning struct {
|
||||
// Slug identifier
|
||||
Slug string `json:"Slug"`
|
||||
// Friendly title
|
||||
Title string `json:"Title"`
|
||||
// Description
|
||||
Description string `json:"Description"`
|
||||
// URL to caniemail.com
|
||||
URL string `json:"URL"`
|
||||
// Category [css, html]
|
||||
Category string `json:"Category"`
|
||||
// Tags
|
||||
Tags []string `json:"Tags"`
|
||||
// Keywords
|
||||
Keywords string `json:"Keywords"`
|
||||
// Test results
|
||||
Results []Result `json:"Results"`
|
||||
// Notes based on results
|
||||
NotesByNumber map[string]string `json:"NotesByNumber"`
|
||||
// Test score calculated from results
|
||||
Score Score `json:"Score"`
|
||||
}
|
||||
|
||||
// Result struct
|
||||
//
|
||||
// swagger:model HTMLCheckResult
|
||||
type Result struct {
|
||||
// Friendly name of result, combining family, platform & version
|
||||
Name string `json:"Name"`
|
||||
// Platform eg: ios, android, windows
|
||||
Platform string `json:"Platform"`
|
||||
// Family eg: Outlook, Mozilla Thunderbird
|
||||
Family string `json:"Family"`
|
||||
// Family version eg: 4.7.1, 2019-10, 10.3
|
||||
Version string `json:"Version"`
|
||||
// Support [yes, no, partial]
|
||||
Support string `json:"Support"`
|
||||
// Note number for partially supported if applicable
|
||||
NoteNumber string `json:"NoteNumber"` // where applicable
|
||||
}
|
||||
|
||||
// Score struct
|
||||
//
|
||||
// swagger:model HTMLCheckScore
|
||||
type Score struct {
|
||||
// Number of matches in the document
|
||||
Found int `json:"Found"`
|
||||
// Total percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Total percentage partially supported
|
||||
Partial float32 `json:"Partial"`
|
||||
// Total percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"`
|
||||
}
|
||||
|
||||
// Total weighted result for all scores
|
||||
//
|
||||
// swagger:model HTMLCheckTotal
|
||||
type Total struct {
|
||||
// Total number of tests done
|
||||
Tests int `json:"Tests"`
|
||||
// Total number of HTML nodes detected in message
|
||||
Nodes int `json:"Nodes"`
|
||||
// Overall percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Overall percentage partially supported
|
||||
Partial float32 `json:"Partial"` // total percentage
|
||||
// Overall percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"` // total percentage
|
||||
}
|
||||
92
utils/linkcheck/main.go
Normal file
92
utils/linkcheck/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package linkcheck handles message links checking
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/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
|
||||
}
|
||||
106
utils/linkcheck/status.go
Normal file
106
utils/linkcheck/status.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/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]
|
||||
}
|
||||
21
utils/linkcheck/structs.go
Normal file
21
utils/linkcheck/structs.go
Normal 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"`
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func PrettyPrint(i interface{}) {
|
||||
}
|
||||
|
||||
// CleanIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
|
||||
func CleanIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
@@ -63,3 +63,14 @@ func CleanIP(s string) string {
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// CleanHTTPIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
func CleanHTTPIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
return "localhost:" + s[5:]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
32
utils/tools/args.go
Normal file
32
utils/tools/args.go
Normal 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
utils/tools/html.go
Normal file
19
utils/tools/html.go
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package tools provides various methods for variouws things
|
||||
// Package tools provides various methods for various things
|
||||
package tools
|
||||
|
||||
import (
|
||||
@@ -23,7 +23,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
|
||||
for _, hdr := range headers {
|
||||
// case-insentitive
|
||||
// case-insensitive
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
|
||||
|
||||
// header := []byte(hdr + ":")
|
||||
|
||||
25
utils/tools/tags.go
Normal file
25
utils/tools/tags.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, removing whitespace and invalid characters
|
||||
func CleanTag(s string) string {
|
||||
s = strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user