Compare commits

...

674 Commits

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

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

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

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

See #87 / #153
2023-08-15 17:13:25 +12:00
Ralph Slooten
f0c77ac962 Merge tag 'v1.8.1' into develop
Release v1.8.1
2023-08-06 17:07:38 +12:00
Ralph Slooten
5dbc585fce Merge branch 'release/v1.8.1' 2023-08-06 17:07:35 +12:00
Ralph Slooten
63fd86499d Release v1.8.1 2023-08-06 17:07:34 +12:00
Ralph Slooten
6db28c5ef7 Libs: Update node modules 2023-08-06 17:04:40 +12:00
Ralph Slooten
92390a0999 Libs: Update Go modules 2023-08-06 17:00:12 +12:00
Ralph Slooten
149bfa80c2 Fix: Check/set message Reply-To using SMTP FROM
Resolves #149 #150
2023-08-06 16:55:58 +12:00
Ralph Slooten
6d2fab1bc6 Docs: Add pagination to swagger search documentation 2023-08-05 16:59:33 +12:00
Ralph Slooten
93a6107df2 Fix: Exclude "sendmail" from recipients list when using mailpit sendmail <options> 2023-08-05 16:54:27 +12:00
Ralph Slooten
8c3705cc5d Add usage docs to README 2023-08-04 14:47:44 +12:00
Ralph Slooten
6379af5604 Update README 2023-08-03 17:34:00 +12:00
Ralph Slooten
103bd564ab Fix: Exclude <script type="application/json"> from HTML check tests 2023-08-03 17:33:50 +12:00
Ralph Slooten
86a4633d24 Merge tag 'v1.8.0' into develop
Release v1.8.0
2023-07-30 19:19:42 +12:00
Ralph Slooten
80fa989a32 Merge branch 'release/v1.8.0' 2023-07-30 19:19:35 +12:00
Ralph Slooten
66850633a1 Release v1.8.0 2023-07-30 19:19:34 +12:00
Ralph Slooten
6c7a1d1ea2 Format error syntax 2023-07-30 18:44:24 +12:00
Ralph Slooten
0998595690 Libs: Update node modules 2023-07-30 18:41:26 +12:00
Ralph Slooten
677b00e29a Libs: Update Go modules 2023-07-30 18:35:15 +12:00
Ralph Slooten
ba8b4366ce Merge branch 'feature/swagger-root' into develop 2023-07-30 17:35:26 +12:00
Ralph Slooten
24fb49d079 Fix: Add basePath to swagger.json if webroot is specified
@See #147
2023-07-30 17:35:17 +12:00
Ralph Slooten
c8a2effac4 Add comment to apiv1 2023-07-30 17:14:23 +12:00
Ralph Slooten
9f63010ca5 Merge branch 'feature/html-check' into develop 2023-07-30 17:12:13 +12:00
Ralph Slooten
f8d514e9e3 Clean up style paths 2023-07-30 17:04:28 +12:00
Ralph Slooten
1922651d41 Feature: HTML check to test & score mail client compatibility with HTML emails 2023-07-30 17:04:06 +12:00
Ralph Slooten
7d2716ee17 UI: Add flag to block all access to remote CSS and fonts (CSP)
This is now set to allow by default.
@see #140
2023-07-29 22:25:37 +12:00
adiabatic
4c1df6f61e Docs: Update brew installation instructions 2023-07-17 21:12:19 +12:00
Ralph Slooten
be3979241f UI: Remove <base /> tag if set in HTML preview 2023-07-16 00:16:45 +12:00
Ralph Slooten
aeb3585f3e Update API documentation 2023-07-12 17:30:03 +12:00
Ralph Slooten
b8de57da27 Merge branch 'feature/search-pagination' into develop 2023-07-12 17:24:35 +12:00
Ralph Slooten
56982798dc Swagger: Update swagger docs 2023-07-12 17:22:48 +12:00
Ralph Slooten
ac0e7163dd UI: Pagination support for search, all results 2023-07-12 17:21:51 +12:00
Ralph Slooten
7638500c05 Merge tag 'v1.7.1' into develop
Release v1.7.1
2023-07-11 16:52:37 +12:00
Ralph Slooten
5d63e9be9e Merge branch 'release/v1.7.1' 2023-07-11 16:52:35 +12:00
Ralph Slooten
672d9b7c26 Release v1.7.1 2023-07-11 16:52:35 +12:00
Ralph Slooten
d9be8f86d7 Libs: Update Go modules 2023-07-11 16:49:30 +12:00
Ralph Slooten
e3e827b180 UI: Wrap HTML source lines
Why does Gmail put everything on a single line?!
2023-07-11 16:47:09 +12:00
Ralph Slooten
daf6e453df UI: Dark mode color adjustments 2023-07-11 16:44:55 +12:00
Ralph Slooten
9cb2c26c6f UI: Update dark mode loading background color 2023-07-11 16:22:53 +12:00
Ralph Slooten
0aa8ea3d51 Merge branch 'feature/bstags' into develop 2023-07-10 20:36:36 +12:00
Ralph Slooten
e05b284c2c Libs: Update node modules 2023-07-10 20:36:26 +12:00
Ralph Slooten
d39b65deb7 Fix typos 2023-07-09 22:33:47 +12:00
Ralph Slooten
7b8faa8a28 Update README 2023-07-01 00:16:02 +12:00
Ralph Slooten
ebb98c99c0 Update screenshot 2023-07-01 00:15:56 +12:00
Ralph Slooten
a726cf9922 Merge tag 'v1.7.0' into develop
Release v1.7.0
2023-06-30 23:14:47 +12:00
Ralph Slooten
5d146a23d7 Merge branch 'release/v1.7.0' 2023-06-30 23:14:39 +12:00
Ralph Slooten
a6c1bbc977 Release v1.7.0 2023-06-30 23:14:38 +12:00
Ralph Slooten
d020861559 Fix styles 2023-06-30 23:10:13 +12:00
Ralph Slooten
7fd3291040 Libs: Update node modules 2023-06-30 23:06:43 +12:00
Ralph Slooten
479c74500c Libs: Update Go modules 2023-06-30 22:57:46 +12:00
Ralph Slooten
6b6de59c47 API: Ignore SMTP relay error when one of multiple recipients doesn't exist
RCPT errors will now produce a warning log message rather than return immediate error. See #132
2023-06-30 22:55:26 +12:00
Ralph Slooten
a5de4e4f65 Merge branch 'feature/dark-mode' into develop 2023-06-30 22:44:06 +12:00
Ralph Slooten
48f22cca1f Code cleanup 2023-06-30 22:42:33 +12:00
Ralph Slooten
7748846b88 UI: Theme toggler - auto, light and dark themes 2023-06-30 22:42:09 +12:00
Ralph Slooten
497086cb65 API: Set raw message Content-Type to UTF-8 2023-06-30 22:18:39 +12:00
Ralph Slooten
42ecadab9e Build: Define Vue build options in esbuild 2023-06-30 22:16:43 +12:00
Júnior Messias
4cfde7f947 Theme toggler (#136)
Add toggler to change theme (light, dark, auto)
2023-06-30 17:13:12 +12:00
Ralph Slooten
70b604e028 Update error message 2023-06-26 17:36:13 +12:00
Ralph Slooten
8c295d4754 Merge tag 'v1.6.22' into develop
Release v1.6.22
2023-06-26 17:32:14 +12:00
Ralph Slooten
a1c34b37e1 Merge branch 'release/v1.6.22' 2023-06-26 17:32:13 +12:00
Ralph Slooten
e37583073e Release v1.6.22 2023-06-26 17:32:12 +12:00
Ralph Slooten
4de830c490 Update Go modules 2023-06-26 17:29:31 +12:00
Ralph Slooten
22a4509b13 Feature: Clearer SMTP error messages 2023-06-26 17:27:41 +12:00
Ralph Slooten
1ed06161a8 Libs: Update Go modules 2023-06-19 20:22:02 +12:00
Ralph Slooten
a7ee479f06 Libs: Upgrade node modules
Includes changes required for bootstrap5-tags
2023-06-19 16:27:57 +12:00
Ralph Slooten
93e8884ef7 Merge tag 'v1.6.21' into develop
Release v1.6.21
2023-06-15 22:25:50 +12:00
Ralph Slooten
1c228cda56 Merge branch 'release/v1.6.21' 2023-06-15 22:25:48 +12:00
Ralph Slooten
119b3864b2 Release v1.6.21 2023-06-15 22:25:47 +12:00
Ralph Slooten
b9f035790d UI: More accurate clickable hyperlink logic in plain text messages
See #125
2023-06-15 22:07:29 +12:00
Ralph Slooten
1260c2e6df Merge tag 'v1.6.20' into develop
Release v1.6.20
2023-06-15 17:34:17 +12:00
Ralph Slooten
3431f18a3f Merge branch 'release/v1.6.20' 2023-06-15 17:34:15 +12:00
Ralph Slooten
2fa5138b49 Release v1.6.20 2023-06-15 17:34:15 +12:00
Ralph Slooten
652fec0f64 Merge branch 'feature/text-clickable-links' into develop 2023-06-15 17:24:42 +12:00
Ralph Slooten
f168e11b05 Libs: Update node modules 2023-06-15 17:24:32 +12:00
Ralph Slooten
35e81e0336 Feature: Convert links into clickable hyperlinks in plain text message content
@see 125
2023-06-15 17:15:46 +12:00
Ralph Slooten
7beed988e5 Merge tag 'v1.6.19' into develop
Release v1.6.19
2023-06-15 09:52:12 +12:00
Ralph Slooten
4eea79f0c8 Merge branch 'release/v1.6.19' 2023-06-15 09:52:10 +12:00
Ralph Slooten
39767e979c Release v1.6.19 2023-06-15 09:52:10 +12:00
Ralph Slooten
4e2f02ee0a Fix: Only display sendmail help when sendmail subcommand is invoked
This was overriding all Mailpit's help commands
2023-06-15 09:50:11 +12:00
Ralph Slooten
5a04534314 Add :key to message in message list 2023-06-15 09:27:26 +12:00
Ralph Slooten
6725a809d5 Increase auto-build package upload retries from 3 to 5 2023-06-14 22:33:36 +12:00
Ralph Slooten
64a067cff9 Merge tag 'v1.6.18' into develop
Release v1.6.18
2023-06-14 22:20:48 +12:00
Ralph Slooten
58dbccc0a7 Merge branch 'release/v1.6.18' 2023-06-14 22:20:40 +12:00
Ralph Slooten
3ef320d277 Release v1.6.18 2023-06-14 22:20:40 +12:00
Ralph Slooten
18e95b699e Merge branch 'feature/labels' into develop 2023-06-14 22:18:59 +12:00
Ralph Slooten
fc89655b7f UI: Add option to enable tag colors based on tag name hash
An experimental option to add tag colors (see #127). This will generate a random color for each unique tag
2023-06-14 22:18:51 +12:00
Ralph Slooten
ff9a6ff491 API: Sort tags before saving
Tags should be alphabetically sorted before saving. Whilst this was the behavior with automated tagging, it did not apply to manually set tags via the API.
2023-06-13 16:57:42 +12:00
Ralph Slooten
adce75ab8f UI: Display message tags below subject in message overview 2023-06-13 15:53:18 +12:00
Ralph Slooten
12903cae60 Merge tag 'v1.6.17' into develop
Release v1.6.17
2023-06-07 07:36:23 +12:00
Ralph Slooten
7f55511c82 Merge branch 'release/v1.6.17' 2023-06-07 07:36:21 +12:00
Ralph Slooten
309036fb6d Release v1.6.17 2023-06-07 07:36:20 +12:00
Ralph Slooten
48387c3a13 Rename hidden long flags 2023-06-07 07:35:34 +12:00
Ronaldo Richieri
a2ab350aff Fix: Add single dash arguments support to sendmail command (#123)
mailpit sendmail -t was not working because of the single dash argument
parsing. This commit fixes it.

Co-authored-by: Ronaldo Richieri <ronaldo@bestpractical.com>
2023-06-07 07:33:47 +12:00
Ralph Slooten
c150f1ba50 Merge tag 'v1.6.16' into develop
Release v1.6.16
2023-06-03 17:19:32 +12:00
Ralph Slooten
48bec0c8f6 Merge branch 'release/v1.6.16' 2023-06-03 17:19:29 +12:00
Ralph Slooten
fef2628c3f Release v1.6.16 2023-06-03 17:19:29 +12:00
Ralph Slooten
e5888ede8b Bugfix: Fix sendmail/startup panic
Fixes #122
2023-06-03 17:18:52 +12:00
Ralph Slooten
374a760b88 Merge tag 'v1.6.15' into develop
Release v1.6.15
2023-06-03 11:06:03 +12:00
Ralph Slooten
0fdfa13a38 Merge branch 'release/v1.6.15' 2023-06-03 11:06:01 +12:00
Ralph Slooten
b41df78c4f Release v1.6.15 2023-06-03 11:06:00 +12:00
Ralph Slooten
870e523c97 Merge branch 'feature/sendmail-bs' into develop 2023-06-03 11:03:17 +12:00
Ralph Slooten
0b391b5c37 Fix error return value 2023-06-03 11:02:53 +12:00
Ralph Slooten
c01f473e79 Feature: Add sendmail -bs functionality
Symfony's mailer uses `sendmail -bs` by default. This adds the required internal telnet functionality to connect directly to the SMTP server.
2023-06-03 10:57:13 +12:00
Ralph Slooten
3c27fd715b Update .gitignore 2023-06-03 10:34:22 +12:00
Ralph Slooten
714596a13a Fix plural 2023-06-02 19:11:10 +12:00
Ralph Slooten
9ae02daf1a Merge tag 'v1.6.14' into develop
Release v1.6.14
2023-06-02 17:22:13 +12:00
Ralph Slooten
b6750600cb Merge branch 'release/v1.6.14' 2023-06-02 17:22:12 +12:00
Ralph Slooten
78e871e9b3 Release v1.6.14 2023-06-02 17:22:12 +12:00
Ralph Slooten
8ff2a5cf6a Merge branch 'feature/tags' into develop 2023-06-02 17:18:46 +12:00
Ralph Slooten
4a88d1fc24 Feature: Add ability to delete or mark search results read
@see #119
2023-06-02 17:17:54 +12:00
Ralph Slooten
d4268b8ae1 Feature: Set tags via X-Tags message header
@see #119
2023-06-02 14:47:36 +12:00
Ralph Slooten
1b47716f5f Libs: Update node modules 2023-06-02 08:28:24 +12:00
Ralph Slooten
42e6d71415 Update API docs 2023-05-31 08:20:05 +12:00
Ralph Slooten
cd5789dda2 Merge tag 'v1.6.13' into develop
Release v1.6.13
2023-05-30 20:38:47 +12:00
Ralph Slooten
cd2a9d433a Merge branch 'release/v1.6.13' 2023-05-30 20:38:45 +12:00
Ralph Slooten
fe0dfe41e7 Release v1.6.13 2023-05-30 20:38:44 +12:00
Ralph Slooten
bee3174c78 Merge branch 'feature/login-auth' into develop 2023-05-30 20:37:23 +12:00
Ralph Slooten
a3187d5499 Merge tag 'v1.6.12' into develop
Release v1.6.12
2023-05-30 16:58:03 +12:00
Ralph Slooten
dc7f047b9a Merge branch 'release/v1.6.12' 2023-05-30 16:58:00 +12:00
Ralph Slooten
f3bb522143 Release v1.6.12 2023-05-30 16:58:00 +12:00
Ralph Slooten
3a41d56cc6 Merge branch 'feature/message-summary-ids' into develop 2023-05-30 16:54:41 +12:00
Ralph Slooten
db5d8f672a Swagger: Update swagger field descriptions, add MessageID 2023-05-30 16:52:39 +12:00
Ralph Slooten
3d96b2cad0 Add Message-ID to MessageSummary 2023-05-30 16:51:34 +12:00
Lars Liedtke
34c1748f4b Feature: Add Message-Id to MessageSummary (#116) 2023-05-30 16:02:25 +12:00
Ralph Slooten
52120abefd Feature: Add SMTP LOGIN authentication method for message relay
See #118
2023-05-30 15:54:26 +12:00
Ralph Slooten
086142e977 Merge tag 'v1.6.11' into develop
Release v1.6.11
2023-05-26 23:02:50 +12:00
Ralph Slooten
078f42f4ea Merge branch 'release/v1.6.11' 2023-05-26 23:02:47 +12:00
Ralph Slooten
df5ded49b8 Release v1.6.11 2023-05-26 23:02:47 +12:00
Ralph Slooten
3bd1eca2ab Libs: Update node modules 2023-05-26 23:00:10 +12:00
Ralph Slooten
95b54ce8a4 Libs: Update Go modules 2023-05-26 22:59:20 +12:00
Ralph Slooten
eb3330939d Update README 2023-05-23 16:07:34 +12:00
Ralph Slooten
50b5f8667a Minor UI / CLI updates 2023-05-23 16:07:05 +12:00
Jonas
a121c08dc4 UI: Check for secure context instead of HTTPS (#114) 2023-05-23 15:36:42 +12:00
Ralph Slooten
9ff9b783cc Merge tag 'v1.6.10' into develop
Release v1.6.10
2023-05-18 10:56:02 +12:00
Ralph Slooten
7f68ea407b Merge branch 'release/v1.6.10' 2023-05-18 10:55:59 +12:00
Ralph Slooten
9a8e7ebdf9 Release v1.6.10 2023-05-18 10:55:59 +12:00
Ralph Slooten
db7f2c1a5d Libs: Update node modules 2023-05-18 10:53:12 +12:00
Ralph Slooten
2ac0b40ecf Libs: Update Go modules 2023-05-18 10:50:35 +12:00
Ralph Slooten
d1edbe73b4 UI: Remove "Noto Color Emoji" from default bootstrap font list
@see #92
2023-05-18 09:38:26 +12:00
Ralph Slooten
24e23790ec Merge tag 'v1.6.9' into develop
Release v1.6.9
2023-05-09 17:18:01 +12:00
Ralph Slooten
bc8722d1cf Merge branch 'release/v1.6.9' 2023-05-09 17:17:58 +12:00
Ralph Slooten
b1e3e1f879 Release v1.6.9 2023-05-09 17:17:58 +12:00
Ralph Slooten
635714945e Libs: Update node modules 2023-05-09 17:15:29 +12:00
Ralph Slooten
1200750111 Libs: Update Go modules 2023-05-09 17:14:15 +12:00
Ralph Slooten
9670c4e1d5 API: Return blank 200 response for OPTIONS requests (CORS) 2023-05-09 17:11:57 +12:00
Ralph Slooten
1e97e9e21f Bugfix: Correctly escape JS cid regex 2023-05-05 22:51:17 +12:00
Ralph Slooten
ca31524487 Merge tag 'v1.6.8' into develop
Release v1.6.8
2023-05-05 22:14:08 +12:00
Ralph Slooten
4800922f91 Merge branch 'release/v1.6.8' 2023-05-05 22:14:05 +12:00
Ralph Slooten
6884cf34fc Release v1.6.8 2023-05-05 22:14:04 +12:00
Ralph Slooten
3b75bf3fa3 Merge branch 'feature/recipient-allowlist' into develop 2023-05-05 22:11:00 +12:00
Ralph Slooten
b4a971f552 Minor code changes 2023-05-05 17:21:43 +12:00
Ralph Slooten
e77d0a750d Correct grammar 2023-05-05 17:07:28 +12:00
Ralph Slooten
bdf887389e Bugfix: Fix Date display when message doesn't contain a Date header 2023-05-05 16:49:59 +12:00
Matthias Gliwka
fdc1b05545 Feature: Add allowlist to filter recipients before relaying messages (#109)
* Bugfix: Don't panic on mails without from line

* Feature: Add allowlist to filter recipients before relaying messages
2023-05-05 15:28:00 +12:00
Ralph Slooten
316b5d7c66 Feature: Add -S short flag for sendmail --smtp-addr 2023-05-05 15:23:51 +12:00
Ralph Slooten
4f13785174 Merge tag 'v1.6.7' into develop
Release v1.6.7
2023-05-05 06:59:11 +12:00
Ralph Slooten
c83acfb255 Merge branch 'release/v1.6.7' 2023-05-05 06:59:09 +12:00
Ralph Slooten
1e8f10732e Release v1.6.7 2023-05-05 06:59:09 +12:00
Ralph Slooten
40bced067e Bugfix: Fix auto-deletion cron
Resolves #107
2023-05-05 06:58:37 +12:00
Ralph Slooten
f2bce03e9e Merge tag 'v1.6.6' into develop
Release v1.6.6
2023-05-04 22:24:42 +12:00
Ralph Slooten
34b62bd08a Merge branch 'release/v1.6.6' 2023-05-04 22:24:39 +12:00
Ralph Slooten
9d64e53b93 Release v1.6.6 2023-05-04 22:24:38 +12:00
Ralph Slooten
16bc025fff API: Set Access-Control-Allow-Headers when --api-cors is set 2023-05-04 22:23:07 +12:00
Ralph Slooten
14a61859f0 Update README
Resolves #105
2023-05-04 22:13:06 +12:00
Ralph Slooten
304a379c30 Bump wangyoucao577/go-release-action from 1.37 to 1.38 2023-05-04 21:55:18 +12:00
Ralph Slooten
82b0829429 Merge branch 'feature/message-id' into develop 2023-05-04 21:53:14 +12:00
Ralph Slooten
25c393d380 Libs: Update node modules 2023-05-04 21:52:16 +12:00
Ralph Slooten
b66f1d0ae1 Libs: Update Go modules 2023-05-04 21:48:45 +12:00
Ralph Slooten
5f919cc9dd Feature: Option to ignore duplicate Message-IDs
This option (default off) silently ignores any new messages with duplicate Message-IDs. This update includes a new database structure and automatic rebuild of existing data.
2023-05-04 21:48:09 +12:00
Ralph Slooten
225a1e2e2a Swagger: Update swagger field descriptions 2023-05-04 21:26:27 +12:00
Ralph Slooten
6dca57ba9b API: Include correct start value in search reponse 2023-05-03 17:20:14 +12:00
Ralph Slooten
60ea473acb UI: Style Undisclosed recipients in message view 2023-05-02 16:51:07 +12:00
Ralph Slooten
0d9b0cdc43 Merge tag 'v1.6.5' into develop
Release v1.6.5
2023-04-25 08:58:24 +12:00
Ralph Slooten
e843de6166 Merge branch 'release/v1.6.5' 2023-04-25 08:58:22 +12:00
Ralph Slooten
b6f2618b34 Release v1.6.5 2023-04-25 08:58:22 +12:00
Ralph Slooten
31c0a501e8 Feature: Add Access-Control-Allow-Methods methods when CORS origin is set
@See #91
2023-04-25 08:57:16 +12:00
Ralph Slooten
08288e904d Merge tag 'v1.6.4' into develop
Release v1.6.4
2023-04-24 22:29:36 +12:00
Ralph Slooten
dfb455c59c Merge branch 'release/v1.6.4' 2023-04-24 22:29:35 +12:00
Ralph Slooten
5e00013a8d Release v1.6.4 2023-04-24 22:29:35 +12:00
Ralph Slooten
c5a8836b7e Bugfix: Fix UI images not displaying when multiple cid names overlap
Resolves #96
2023-04-24 22:27:57 +12:00
Ralph Slooten
ae73c721db Merge tag 'v1.6.3' into develop
Release v1.6.3
2023-04-24 11:36:03 +12:00
Ralph Slooten
9ae9104ca3 Merge branch 'release/v1.6.3' 2023-04-24 11:36:00 +12:00
Ralph Slooten
aa2dc4cf62 Release v1.6.3 2023-04-24 11:36:00 +12:00
Ralph Slooten
cffbd3f884 Feature: Display clickable toast notifications for new messages
Resolves #97
2023-04-24 11:34:43 +12:00
Ralph Slooten
a05cc59800 Merge tag 'v1.6.2' into develop
Release v1.6.2
2023-04-21 22:31:03 +12:00
Ralph Slooten
924ad9b064 Merge branch 'release/v1.6.2' 2023-04-21 22:31:01 +12:00
Ralph Slooten
b63e9b465b Release v1.6.2 2023-04-21 22:31:01 +12:00
Ralph Slooten
124f1c2bde Bugfix: If set use return-path address as SMTP from address 2023-04-21 22:30:02 +12:00
Ralph Slooten
64461c17a1 Merge tag 'v1.6.1' into develop
Release v1.6.1
2023-04-21 17:51:14 +12:00
Ralph Slooten
0ff6b18b43 Merge branch 'release/v1.6.1' 2023-04-21 17:51:13 +12:00
Ralph Slooten
1a638cf8ea Release v1.6.1 2023-04-21 17:51:12 +12:00
Ralph Slooten
126fa66d58 Bugfix: Add API release route again (bad merge) 2023-04-21 17:50:34 +12:00
Ralph Slooten
1f95461651 Merge tag 'v1.6.0' into develop
Release v1.6.0
2023-04-21 14:22:09 +12:00
Ralph Slooten
176f128057 Merge branch 'release/v1.6.0' 2023-04-21 14:22:05 +12:00
Ralph Slooten
031b5697e4 Release v1.6.0 2023-04-21 14:22:05 +12:00
Ralph Slooten
19f51c8931 Update README 2023-04-21 14:19:32 +12:00
Ralph Slooten
7c62dca14b API: Enable cross-origin resource sharing (CORS) configuration
This feature allows the setting of the `Access-Control-Allow-Origin` header via `--api-cors`.

@see #91
2023-04-21 12:49:49 +12:00
Ralph Slooten
584d94b8e7 Merge branch 'feature/message-release' into develop 2023-04-21 12:20:46 +12:00
Ralph Slooten
23370eab0f Update Swagger documentation 2023-04-21 12:19:12 +12:00
Ralph Slooten
4f5b5e2f02 UI: Display Return-Path if different to the From address 2023-04-21 12:18:01 +12:00
Ralph Slooten
def9602811 UI: Message release functionality
When an SMTP relay server is configured, the web UI will display a "Release" button and allow a message to be manually relayed via the SMTP server to selected addresses.

@see #29
2023-04-21 12:17:14 +12:00
Ralph Slooten
3d63a27458 Correctly parse -f argument for sendmail 2023-04-21 12:13:05 +12:00
Ralph Slooten
389f248603 Libs: Update Go modules 2023-04-21 12:12:14 +12:00
Ralph Slooten
04462f76c6 API: Message relay / release
This enables a SMTP server to be configured, and messages to be manually "released" via the relay server. Aditionally, messages can be auto-relayed via the SMTP server do Mailpit acts as a form of caching proxy.

@see #29
2023-04-21 12:10:13 +12:00
Ralph Slooten
2752a09ca7 Move logging variable level to logger module 2023-04-21 11:59:26 +12:00
Ralph Slooten
8eed8d92e5 Update swagger comments 2023-04-21 11:55:32 +12:00
Ralph Slooten
6a82dd0eb2 API: Include Return-Path in message summary data 2023-04-21 11:44:34 +12:00
Ralph Slooten
b5b0c173c3 Libs: Update node modules 2023-04-21 11:41:43 +12:00
Ralph Slooten
9c8329a05c Feature: Inject/update Bcc header for missing addresses when SMTP recipients do not match messsage headers
In order to capture Bcc recipients from some platfoms (eg: Laravel) when the SMTP recipients contain Bcc recipients but are not listed in the message headers, the missing addresses are now added into the message Bcc header. If the Bcc header does not exist then it is created.

Resolves #35
2023-04-15 11:34:31 +12:00
Ralph Slooten
7c329b56f8 Merge tag 'v1.5.5' into develop
Release v1.5.5
2023-04-12 17:06:43 +12:00
Ralph Slooten
26a84bc257 Merge branch 'release/v1.5.5' 2023-04-12 17:06:42 +12:00
Ralph Slooten
d65de12714 Release v1.5.5 2023-04-12 17:06:41 +12:00
Ralph Slooten
5ed55e58e1 Show swagger curl example before try 2023-04-12 17:04:42 +12:00
Ralph Slooten
84d3384120 Display service listening IPs as 0.0.0.0 when set to default [::] 2023-04-12 16:22:20 +12:00
Thomas Jepp
efc9c10f83 Feature: Update listen regex to allow IPv6 addresses (#85) 2023-04-12 16:03:36 +12:00
Ralph Slooten
962af81653 Bump version of booxmedialtd/ws-action-parse-semver 2023-04-04 17:02:31 +12:00
Ralph Slooten
7deddc3119 Reduce duration for stale issues to 21 days of inactivity 2023-04-04 01:07:50 +12:00
Ralph Slooten
058bc31e28 Docker: Add Docker image tag for major/minor version
See #82
2023-04-04 00:55:30 +12:00
Ralph Slooten
8e84b96233 Update README 2023-04-03 18:53:11 +12:00
Ralph Slooten
a8dddbaa7b Merge tag 'v1.5.4' into develop
Release v1.5.4
2023-04-03 18:47:50 +12:00
Ralph Slooten
8f9876a0a3 Merge branch 'release/v1.5.4' 2023-04-03 18:47:48 +12:00
Ralph Slooten
17ecdb6165 Release v1.5.4 2023-04-03 18:47:48 +12:00
Ralph Slooten
eba934c0e0 Feature: Mobile and tablet HTML preview toggle in desktop mode 2023-04-03 18:46:40 +12:00
Ralph Slooten
31885008ed Merge tag 'v1.5.3' into develop
Release v1.5.3
2023-04-01 22:38:46 +13:00
Ralph Slooten
c48da61097 Merge branch 'release/v1.5.3' 2023-04-01 22:38:42 +13:00
Ralph Slooten
c532870adc Release v1.5.3 2023-04-01 22:38:41 +13:00
Ralph Slooten
85291683b6 Bugfix: Enable SMTP auth flags to be set via env
Fixes #77
2023-04-01 22:37:31 +13:00
dependabot[bot]
09399db612 Bump actions/stale from 7.0.0 to 8.0.0 (#79)
Bumps [actions/stale](https://github.com/actions/stale) from 7.0.0 to 8.0.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v7.0.0...v8.0.0)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 21:14:18 +13:00
dependabot[bot]
ea753f6948 Bump wangyoucao577/go-release-action from 1.36 to 1.37 (#80)
Bumps [wangyoucao577/go-release-action](https://github.com/wangyoucao577/go-release-action) from 1.36 to 1.37.
- [Release notes](https://github.com/wangyoucao577/go-release-action/releases)
- [Commits](https://github.com/wangyoucao577/go-release-action/compare/v1.36...v1.37)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 21:13:55 +13:00
dependabot[bot]
0f73f7d261 Bump actions/setup-go from 3 to 4 (#81)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 21:12:36 +13:00
Ralph Slooten
e188325ddd Merge tag 'v1.5.2' into develop
Release v1.5.2
2023-04-01 17:08:12 +13:00
Ralph Slooten
6ab6d5fa2d Merge branch 'release/v1.5.2' 2023-04-01 17:08:10 +13:00
Ralph Slooten
f6545b55a4 Release v1.5.2 2023-04-01 17:08:09 +13:00
Ralph Slooten
1b798c5514 UI: Tab to view formatted message headers
See #71
2023-04-01 17:06:30 +13:00
Ralph Slooten
f16b105d26 API: Include Reply-To in message summary (including Web UI)
This adds a new ReplyTo array to to API Message response, and displays in the web UI if set. See #66
2023-04-01 17:05:18 +13:00
Ralph Slooten
af7df617af Merge branch 'release/v1.5.1' 2023-03-31 22:46:27 +13:00
Ralph Slooten
4e6d8e5803 Release v1.5.1 2023-03-31 22:46:26 +13:00
Ralph Slooten
14d2715832 Libs: Update Go modules 2023-03-31 22:45:18 +13:00
Ralph Slooten
6d902293c1 Feature: Add 'o', 'b' & 's' ignored flags for sendmail
Resolves #76
2023-03-31 22:42:35 +13:00
Ralph Slooten
b423c26537 Libs: Update node modules 2023-03-31 22:34:43 +13:00
Ralph Slooten
75db0e2911 Release v1.5.0 2023-03-31 18:51:52 +13:00
Ralph Slooten
0f21f2e4b5 Merge tag 'v1.5.0' into develop
Release v1.5.0
2023-03-31 18:51:52 +13:00
Ralph Slooten
c4a695e627 Merge branch 'release/v1.5.0' 2023-03-31 18:51:20 +13:00
Ralph Slooten
62cf75f8fb Release v1.5.0 2023-03-31 18:48:29 +13:00
Ralph Slooten
5350e2eb08 Feature: OpenAPI / Swagger schema
Mailpit now has built-in OpenAPI / Swagger documentation, see #65
2023-03-31 18:44:08 +13:00
Ralph Slooten
3bb9f4162a Feature: Download raw message, HTML/text body parts or attachments via single button
@see #67
2023-03-31 18:44:08 +13:00
Ralph Slooten
2d07683a28 Bugfix: Fix JavaScript error when adding the first tag manually
Caused when passing updated prov values via Vue components, see #68
2023-03-31 18:44:08 +13:00
Oliver Matla
fc753677f6 Correct typing mistake in README introduction 2023-03-31 18:44:07 +13:00
Ralph Slooten
ab0c91545a Release v1.4.0 2023-03-31 18:44:07 +13:00
Ralph Slooten
b6e1b68c90 Update README 2023-03-31 18:44:07 +13:00
Ralph Slooten
182d32a2c8 API: Return received datetime when message does not contain a date header 2023-03-31 18:44:06 +13:00
Ralph Slooten
169c476c56 Feature: Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL 2023-03-31 18:44:06 +13:00
Ralph Slooten
57b0e1666f Feature: Options to support auth without STARTTLS, and accept any login
@see #56
2023-03-31 18:44:06 +13:00
Ralph Slooten
a9ce35b741 Feature: Option to use message dates as received dates (new messages only) 2023-03-31 18:44:05 +13:00
Ralph Slooten
fb03fda9ea Merge branch 'release/v1.4.0' 2023-03-12 15:08:18 +13:00
Ralph Slooten
e2254a68ef Release v1.4.0 2023-03-12 15:08:17 +13:00
Ralph Slooten
755ff37cdc Merge branch 'feature/smtp-auth' into develop 2023-03-12 15:06:44 +13:00
Ralph Slooten
03f30b01bf Update README 2023-03-12 15:06:26 +13:00
Ralph Slooten
27d49417d7 API: Return received datetime when message does not contain a date header 2023-03-12 14:27:20 +13:00
Ralph Slooten
aeeb732681 Feature: Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL 2023-03-12 11:31:15 +13:00
Ralph Slooten
73a92a3952 Feature: Options to support auth without STARTTLS, and accept any login
@see #56
2023-03-12 10:51:49 +13:00
Ralph Slooten
9cd81afe7c Feature: Option to use message dates as received dates (new messages only) 2023-03-10 16:42:14 +13:00
Ralph Slooten
41270b956e Merge tag 'v1.3.11' into develop
Release v1.3.11
2023-03-10 14:57:09 +13:00
Ralph Slooten
dfad730b21 Release v1.3.11 2023-03-10 14:57:08 +13:00
Ralph Slooten
3d31ae7da4 Merge branch 'release/v1.3.11' 2023-03-10 14:57:08 +13:00
Ralph Slooten
f0723fb64a Update git-chglog format 2023-03-10 14:52:35 +13:00
Ralph Slooten
b905ba4ec5 Feature: Expand custom webroot path to include a-z A-Z 0-9 _ . - and /
@see #64
2023-03-10 14:48:28 +13:00
Ralph Slooten
7675cd162f Docker: Expose default ports (1025/tcp 8025/tcp)
@see #63
2023-03-09 15:49:47 +13:00
Ralph Slooten
dff5a605b4 Create FUNDING.yml 2023-03-09 01:18:00 +13:00
Ralph Slooten
3f3b8a6d97 Merge tag 'v1.3.10' into develop
Release v1.3.10
2023-03-04 23:40:31 +13:00
Ralph Slooten
fc595c031d Merge branch 'release/v1.3.10' 2023-03-04 23:40:28 +13:00
Ralph Slooten
a897004dc1 Release v1.3.10 2023-03-04 23:40:27 +13:00
Ralph Slooten
6917477533 Libs: Update node modules 2023-03-04 23:38:05 +13:00
Ralph Slooten
eede2bff99 Bugfix: Fix search with existing emails 2023-03-04 23:25:55 +13:00
Ralph Slooten
de0549e60a Change Dependabot checks to monthly 2023-02-27 21:57:38 +13:00
Ralph Slooten
17caa21afd Update libs 2023-02-27 21:56:47 +13:00
Ralph Slooten
4656717046 Merge tag 'v1.3.9' into develop
Release v1.3.9
2023-02-24 22:31:25 +13:00
Ralph Slooten
72fdbb8364 Merge branch 'release/v1.3.9' 2023-02-24 22:31:21 +13:00
Ralph Slooten
37b4f1f566 Release v1.3.9 2023-02-24 22:31:21 +13:00
Ralph Slooten
464fbf818c Merge pull request #44 from axllent/dependabot/github_actions/wangyoucao577/go-release-action-1.36
Bump wangyoucao577/go-release-action from 1.30 to 1.36
2023-02-24 21:53:47 +13:00
Ralph Slooten
6360a69ff6 Merge pull request #43 from axllent/dependabot/github_actions/docker/build-push-action-4
Bump docker/build-push-action from 3 to 4
2023-02-24 21:53:19 +13:00
Ralph Slooten
054438b952 Libs: Update node modules
Note: due to an  incompatibility issue, esbuild-sass-plugin has been left as-is, see: https://github.com/glromeo/esbuild-sass-plugin/issues/121
2023-02-24 21:50:58 +13:00
Ralph Slooten
cb6085790b Libs: Update Go modules 2023-02-24 21:39:45 +13:00
Ralph Slooten
1bd0c6ac74 Feature: Add Cc and Bcc search filters 2023-02-24 21:36:42 +13:00
Ralph Slooten
7cb46ba869 Correctly case Cc and Bcc in UI 2023-02-24 21:36:42 +13:00
Ralph Slooten
6efe99ffdf Move smtpd to server 2023-02-24 21:36:42 +13:00
Ralph Slooten
cc121e4b27 Merge pull request #55 from axllent/dependabot/go_modules/golang.org/x/image-0.5.0
Bump golang.org/x/image from 0.3.0 to 0.5.0
2023-02-24 21:30:55 +13:00
dependabot[bot]
ee86260651 Bump golang.org/x/image from 0.3.0 to 0.5.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.3.0 to 0.5.0.
- [Release notes](https://github.com/golang/image/releases)
- [Commits](https://github.com/golang/image/compare/v0.3.0...v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-24 08:24:21 +00:00
dependabot[bot]
cab9f8a729 Bump wangyoucao577/go-release-action from 1.30 to 1.36
Bumps [wangyoucao577/go-release-action](https://github.com/wangyoucao577/go-release-action) from 1.30 to 1.36.
- [Release notes](https://github.com/wangyoucao577/go-release-action/releases)
- [Commits](https://github.com/wangyoucao577/go-release-action/compare/v1.30...v1.36)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-24 08:23:12 +00:00
dependabot[bot]
790fbe69fd Bump docker/build-push-action from 3 to 4
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-24 08:23:11 +00:00
Ralph Slooten
51074a9d72 Merge pull request #42 from shizunge/dependabot
Add dependabot.yml
2023-02-24 21:22:51 +13:00
Shizun Ge
28b4f2d09d add npm to dependabot.yml 2023-02-23 20:23:37 -08:00
Shizun Ge
b6c1c180c9 add dependabot.yml 2023-02-22 23:31:30 -08:00
Ralph Slooten
264ad1bf9f Merge tag 'v1.3.8' into develop
Release v1.3.8
2023-02-09 15:24:41 +13:00
Ralph Slooten
7d63c75557 Merge branch 'release/v1.3.8' 2023-02-09 15:24:35 +13:00
Ralph Slooten
0c4c2881c8 Release v1.3.8 2023-02-09 15:24:35 +13:00
Ralph Slooten
56999e97e2 UI: Compress SVG icons 2023-02-09 15:21:46 +13:00
Ralph Slooten
d238675011 Bugfix: Restore notification icon
Fixes #34
2023-02-09 15:21:08 +13:00
Ralph Slooten
fea3b0a422 Update README 2023-02-06 15:27:08 +13:00
Ralph Slooten
24b1dfa040 Update REDAME 2023-02-06 15:24:36 +13:00
Ralph Slooten
ab73a4bcfb Merge tag 'v1.3.7' into develop
Release v1.3.7
2023-01-30 22:21:28 +13:00
Ralph Slooten
df3b27b5e0 Merge branch 'release/v1.3.7' 2023-01-30 22:21:22 +13:00
Ralph Slooten
52bf19a40c Release v1.3.7 2023-01-30 22:21:22 +13:00
Matthias Fechner
c1694f1a22 Feature: Add Kubernetes API health (livez/readyz) endpoints
Kubernetes checks if a pod is ok and if it can retrieve traffic using probes.
This commit add two routes to make a liveness probe and a readiness probe.
2023-01-30 22:17:54 +13:00
Ralph Slooten
894da47eda Libs: Upgrade to esbuild 0.17.5 2023-01-30 22:01:34 +13:00
Ralph Slooten
1718ec00e5 Fix typo 2023-01-14 00:31:03 +13:00
Ralph Slooten
70df34d071 Merge tag 'v1.3.6' into develop
Release v1.3.6
2023-01-12 16:12:20 +13:00
Ralph Slooten
d101ec045d Merge branch 'release/v1.3.6' 2023-01-12 16:12:18 +13:00
Ralph Slooten
a1d8840da2 Release v1.3.6 2023-01-12 16:12:18 +13:00
Ralph Slooten
ed1bb83bda Libs: Update node modules 2023-01-12 16:09:27 +13:00
Ralph Slooten
4b2e8b0174 Libs: Update go modules 2023-01-12 16:07:38 +13:00
Ralph Slooten
594c4817a4 Bugfix: Correctly index missing 'From' header in database
When an email with a missing `From: ` header is stored in the database, a null value was stored. This broke the search. Fixes #31
2023-01-12 16:04:08 +13:00
Ralph Slooten
47a556d05e Merge tag 'v1.3.5' into develop
Release v1.3.5
2023-01-05 11:58:29 +13:00
Ralph Slooten
e3e7c09e81 Merge branch 'release/v1.3.5' 2023-01-05 11:58:06 +13:00
Ralph Slooten
98a932ecdb Release v1.3.5 2023-01-05 11:57:53 +13:00
Ralph Slooten
d47eb09c54 Bugfix: Include HTML link text in search data
`<a href="https://example.com">search text</a>` now stores `search text https://example.com` in the database.

Resolves #30
2023-01-05 11:55:18 +13:00
Ralph Slooten
acee53537c Add automation to close stale issues 2022-12-23 16:20:41 +13:00
Ralph Slooten
b18bcebd51 Fix error casing 2022-12-15 22:09:03 +13:00
Ralph Slooten
0502056678 Merge tag 'v1.3.4' into develop
Release v1.3.4
2022-12-09 10:28:24 +13:00
Ralph Slooten
6901a20661 Merge branch 'release/v1.3.4' 2022-12-09 10:28:21 +13:00
Ralph Slooten
10752a58c8 Release v1.3.4 2022-12-09 10:28:21 +13:00
Ralph Slooten
c8bf742c18 Bugfix: Allow tags to be set from MP_TAG environment
Relates to #26
2022-12-09 10:27:37 +13:00
Ralph Slooten
7313862ad5 Merge tag 'v1.3.3' into develop
Release v1.3.3
2022-12-09 09:33:05 +13:00
Ralph Slooten
8976124b3d Merge branch 'release/v1.3.3' 2022-12-09 09:33:03 +13:00
Ralph Slooten
4fbff688ec Release v1.3.3 2022-12-09 09:33:03 +13:00
Ralph Slooten
dca70a50c3 Bugfix: Allow tags to be set from MP_TAG environment
Fixes #26
2022-12-09 09:32:07 +13:00
Ralph Slooten
eb50304a13 Merge tag 'v1.3.2' into develop
Release v1.3.2
2022-12-08 22:01:50 +13:00
Ralph Slooten
858dfca321 Merge branch 'release/v1.3.2' 2022-12-08 22:01:47 +13:00
Ralph Slooten
5e09dec667 Release v1.3.2 2022-12-08 22:01:47 +13:00
Ralph Slooten
638ea3efa8 ### Libs
- Upgrade esbuild to 0.16.2
2022-12-08 22:01:21 +13:00
Ralph Slooten
06bfc3b6e3 Build: Temporarily disable arm (32) Docker build
Seems to be causing github actions to get stuck for hours (11+).
2022-12-08 21:59:42 +13:00
Ralph Slooten
c2d34f3071 Merge tag 'v1.3.1' into develop
Release v1.3.1
2022-12-08 10:22:23 +13:00
Ralph Slooten
be582291c7 Merge branch 'release/v1.3.1' 2022-12-08 10:22:21 +13:00
Ralph Slooten
646fe072be Release v1.3.1 2022-12-08 10:22:20 +13:00
Ralph Slooten
deba47f6d1 Libs: Upgrade esbuild & axios 2022-12-08 10:15:24 +13:00
Ralph Slooten
5f9efebeb3 Bugfix: Append trailing slash to custom webroot for UI & API
Fixes #25
2022-12-08 09:54:03 +13:00
Ralph Slooten
06aa7a2dea Remove redundant offcanvas mixin 2022-12-03 17:21:51 +13:00
Ralph Slooten
2c3c436fc1 UI: Rename "results" to "result" when singular message returned 2022-11-25 19:06:41 +13:00
Ralph Slooten
6f2dd83936 Merge tag 'v1.3.0' into develop
Release v1.3.0
2022-11-22 22:25:06 +13:00
Ralph Slooten
b850c89ae0 Merge branch 'release/v1.3.0' 2022-11-22 22:24:55 +13:00
Ralph Slooten
cc327ab3ba Release v1.3.0 2022-11-22 22:24:55 +13:00
Ralph Slooten
1886d78001 Libs: Update go modules 2022-11-22 22:18:33 +13:00
Ralph Slooten
63cbafa182 Libs: Update node modules
Including axios, bootstrap, bootstrap5-tags, esbuild, esbuild-sass-plugin, vue
2022-11-22 22:16:46 +13:00
Ralph Slooten
95dacfc5db Build: Remove duplicate bootstrap CSS 2022-11-21 21:43:30 +13:00
Ralph Slooten
067d218f4b Merge tag 'v1.2.9' into develop
Release v1.2.9
2022-11-18 13:26:32 +13:00
Ralph Slooten
3dd004ea4b Merge branch 'release/v1.2.9' 2022-11-18 13:26:29 +13:00
Ralph Slooten
6570217bfd Release v1.2.9 2022-11-18 13:26:29 +13:00
Ralph Slooten
54635b748a Bugfix: Delay 200ms to set target="_blank" for all rendered email links
Fixes #22
2022-11-18 13:25:15 +13:00
Ralph Slooten
0ea4cab33b Merge tag 'v1.2.8' into develop
Release v1.2.8
2022-11-13 17:29:43 +13:00
Ralph Slooten
0fde942e0d Merge branch 'release/v1.2.8' 2022-11-13 17:29:41 +13:00
Ralph Slooten
b09d7ac75d Release v1.2.8 2022-11-13 17:29:40 +13:00
Ralph Slooten
fc2fdd20f6 Update README - add tagging 2022-11-13 17:26:29 +13:00
Ralph Slooten
cbbac40c0d Add MP_TAG environment option 2022-11-13 17:26:29 +13:00
Ralph Slooten
6bc02fd4d4 Feature: Message tags and auto-tagging
See #17
2022-11-13 17:26:29 +13:00
Ralph Slooten
57cfb2611c Use bytes.NewReader(data) instead of strings.NewReader(string(data)) 2022-11-13 17:26:28 +13:00
Ralph Slooten
ba24d145ff Bugfix: Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
Bugfix: Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
2022-11-13 17:26:17 +13:00
Ralph Slooten
376e799eb0 Update README 2022-11-13 17:26:17 +13:00
Ralph Slooten
1dfadda07e Use path.Join() instead of url.JoinPath() for < 1.19 compatibility 2022-11-13 17:26:17 +13:00
Ralph Slooten
fc0a7358ab Merge branch 'release/v1.2.7' 2022-10-31 22:15:25 +13:00
Ralph Slooten
d229b34d98 Release v1.2.7 2022-10-31 22:15:24 +13:00
Ralph Slooten
cbc3fe59a8 Feature: Allow custom webroot
Allow Mailpit to run on a custom webroot, resolves #19
2022-10-31 22:13:41 +13:00
Ralph Slooten
ab771cf76c Move utils to subfolder 2022-10-29 10:52:22 +13:00
Ralph Slooten
7a27e09d23 Merge tag 'v1.2.6' into develop
Release v1.2.6
2022-10-29 10:23:32 +13:00
Ralph Slooten
cdce989a9c Merge branch 'release/v1.2.6' 2022-10-29 10:23:30 +13:00
Ralph Slooten
61dd3eddc5 Release v1.2.6 2022-10-29 10:23:29 +13:00
Ralph Slooten
290e48d875 Libs: Update go modules 2022-10-29 10:22:12 +13:00
Ralph Slooten
e7ea94a5d2 Libs: Update node modules 2022-10-29 10:22:05 +13:00
Ralph Slooten
43bd2a18ea API: Provide structs of API v1 responses for use in client code
See #21
2022-10-21 22:55:15 +13:00
Ralph Slooten
ec95e58e13 Use ${{ github.ref_name }} for workflow build tags 2022-10-16 12:12:28 +13:00
Ralph Slooten
70ac9c73ea Release 1.2.5 2022-10-16 12:07:20 +13:00
Ralph Slooten
0fcdcdd5f6 Merge tag '1.2.5' into develop
Release 1.2.5
2022-10-16 12:04:51 +13:00
Ralph Slooten
ea12a1ee56 Merge branch 'release/1.2.5' 2022-10-16 12:04:30 +13:00
Ralph Slooten
9345ed60c6 Update screenshot 2022-10-16 12:01:40 +13:00
Ralph Slooten
0a13cf8304 Tidy JS code 2022-10-16 11:51:20 +13:00
Ralph Slooten
4ebbdab7c0 Snapshot memory usage first 2022-10-16 11:36:28 +13:00
Ralph Slooten
cea9518b4b UI mobile tweaks 2022-10-16 10:45:04 +13:00
Ralph Slooten
a9220277d6 Refresh first page after prune when !results 2022-10-16 10:21:57 +13:00
Ralph Slooten
bd45d9dffe UI: Broadcast "delete all" action to reload all connected clients 2022-10-16 08:37:46 +13:00
Ralph Slooten
baaf3a3a23 UI tweaks 2022-10-16 00:03:16 +13:00
Ralph Slooten
2e95a75d32 Update Vue 2022-10-15 23:46:53 +13:00
Ralph Slooten
53d2296ff5 Minor UI changes 2022-10-15 23:37:22 +13:00
Ralph Slooten
e8bf803ca0 UI: Load first page if paginated list returns 0 results 2022-10-15 23:30:09 +13:00
Ralph Slooten
d9dc000e89 UI: Theme changes 2022-10-15 23:14:51 +13:00
Ralph Slooten
205611856b UI: Bump build action to use node 18 2022-10-15 09:41:33 +13:00
Ralph Slooten
5d396b9f25 Update build workflow 2022-10-15 09:31:29 +13:00
Ralph Slooten
4b95c6bda0 Merge tag '1.2.4' into develop
Release 1.2.4
2022-10-15 09:02:19 +13:00
Ralph Slooten
9982948c81 Merge branch 'release/1.2.4' 2022-10-15 09:02:17 +13:00
Ralph Slooten
614b63cf28 Release 1.2.4 2022-10-15 09:02:16 +13:00
Martin
b1027ca844 Bugfix: Fix mail download link 2022-10-15 08:54:36 +13:00
Ralph Slooten
2176ad6ca2 Update API query parameters for search 2022-10-14 17:38:22 +13:00
Ralph Slooten
971753e576 Merge tag '1.2.3' into develop
Release 1.2.3
2022-10-14 17:32:01 +13:00
Ralph Slooten
9053651cc1 Merge branch 'release/1.2.3' 2022-10-14 17:31:56 +13:00
Ralph Slooten
a9593030ab Release 1.2.3 2022-10-14 17:31:56 +13:00
Ralph Slooten
75a7c1cfd4 Update API query parameters for search 2022-10-14 17:31:35 +13:00
Ralph Slooten
699a534632 API: Add limit and start parameters to search
Requested in #15
2022-10-14 17:31:35 +13:00
Ralph Slooten
53f8d34961 UI: Prevent double message index request on websocket connect 2022-10-14 17:30:48 +13:00
Ralph Slooten
81d09aabd1 Add linux/386 docker builds 2022-10-14 17:29:33 +13:00
Ralph Slooten
11eec7db30 Add linux-arm to release matrix 2022-10-14 17:29:33 +13:00
Ralph Slooten
6e6482f6ad Merge branch 'release/1.2.2' 2022-10-13 13:20:14 +13:00
Ralph Slooten
1efbbb353b Do not build windows-386 binaries 2022-10-13 13:18:49 +13:00
Ralph Slooten
b61fbe371a Merge tag '1.2.2' into develop
Release 1.2.2
2022-10-13 08:14:46 +13:00
Ralph Slooten
a2b6107dd6 Merge branch 'release/1.2.2' 2022-10-13 08:14:42 +13:00
Ralph Slooten
f457412f98 Release 1.2.2 2022-10-13 08:14:41 +13:00
Ralph Slooten
14f1d75dba Merge branch 'feature/headers' into develop 2022-10-13 08:14:10 +13:00
Ralph Slooten
ce838dc054 Merge tag '1.2.2' into develop
Release 1.2.2
2022-10-13 08:11:36 +13:00
Ralph Slooten
0d29f3db1a Merge branch 'release/1.2.2' 2022-10-13 08:11:35 +13:00
Ralph Slooten
cbc77530e9 Release 1.2.2 2022-10-13 08:11:35 +13:00
Ralph Slooten
70e8edf648 Update docs 2022-10-13 08:11:18 +13:00
Ralph Slooten
4368541a96 Update logging format 2022-10-13 02:53:53 +13:00
Ralph Slooten
4d511bd29d Testing: Add API test for raw & message headers 2022-10-13 02:48:23 +13:00
Ralph Slooten
b0894a8064 API: Add API endpoint to return message headers
See #15
2022-10-13 02:47:51 +13:00
Ralph Slooten
5d32d5190d Libs: Update go modules 2022-10-08 23:59:15 +13:00
Ralph Slooten
b7154963c5 Merge tag '1.2.1' into develop
Release 1.2.1
2022-10-08 23:35:28 +13:00
Ralph Slooten
001e9de123 Merge branch 'release/1.2.1' 2022-10-08 23:35:23 +13:00
Ralph Slooten
b64a5b7991 Release 1.2.1 2022-10-08 23:35:23 +13:00
Ralph Slooten
906a697542 Add event.preventDefault() 2022-10-08 23:34:20 +13:00
Ralph Slooten
46dbde04ae UI: Update frontend modules 2022-10-08 23:34:20 +13:00
Ralph Slooten
a31a7c3d2c UI: Add about app modal with version update notification 2022-10-08 23:33:59 +13:00
Ralph Slooten
675704ca91 Update screenshot path 2022-10-08 23:33:58 +13:00
Ralph Slooten
d253d3164e Merge branch 'release/1.2.0' 2022-10-07 19:54:52 +13:00
Ralph Slooten
ef3da383da Release 1.2.0 2022-10-07 19:54:51 +13:00
Ralph Slooten
db6c2596a0 Merge branch 'feature/apiv1' into develop 2022-10-07 19:53:39 +13:00
Ralph Slooten
7349d838bb Fix typo 2022-10-07 19:49:19 +13:00
Ralph Slooten
d8c6364622 Testing: Add API tests 2022-10-07 19:48:50 +13:00
Ralph Slooten
df758d063a UI: Changes to use new data API 2022-10-07 19:47:41 +13:00
Ralph Slooten
34da0e5042 Feature: Add REST API
Requested feature for integration, see #15
2022-10-07 19:46:39 +13:00
Ralph Slooten
4a92b99a53 Optimise Mailpit SVG logo 2022-10-07 19:25:26 +13:00
Ralph Slooten
b1dc121cdd UI: Hide delete all / mark all read in message view 2022-10-04 17:41:25 +13:00
Ralph Slooten
e5c8ef9e8d Remove redundant files 2022-10-04 17:38:49 +13:00
Ralph Slooten
c6695c2418 Merge tag '1.1.7' into develop
Release 1.1.7
2022-09-21 16:01:10 +12:00
Ralph Slooten
53bbf4c7dc Merge branch 'release/1.1.7' 2022-09-21 16:01:08 +12:00
Ralph Slooten
0015300920 Release 1.1.7 2022-09-21 16:01:08 +12:00
Ralph Slooten
fa6a5d729f Release 1.1.7 2022-09-21 15:56:38 +12:00
Ralph Slooten
cc9fba7adf Fix: Normalize running binary name detection (Windows)
This prevents invoking sendmail when the executed name differs from the actual binary name (eg: running `mailpit` instead of `mailpit.exe`). See #14
2022-09-21 15:56:20 +12:00
Ralph Slooten
93665656cf Invoke loadMessages() before event connect()
In the case whereby the websocket is blocked (ie: error), make sure messages load is already triggered.
2022-09-21 15:56:20 +12:00
Ralph Slooten
d918fdb137 Release 1.1.6 2022-09-19 22:18:00 +12:00
Ralph Slooten
fd1346c5f4 Fix: Workaround for Safari source matching bug blocking event listener
The current stable version of Safari does not treat ws: or wss: sockets as `self`.
See: https://bugs.webkit.org/show_bug.cgi?id=235873

Resolves #13
2022-09-19 22:17:20 +12:00
Ralph Slooten
388bea740b UI: Add documentation link (wiki) 2022-09-17 08:09:22 +12:00
Ralph Slooten
583df9ee1f Merge tag '1.1.5' into develop
Release 1.1.5
2022-09-16 23:27:57 +12:00
Ralph Slooten
8f05b97947 Merge branch 'release/1.1.5' 2022-09-16 23:27:55 +12:00
Ralph Slooten
8bdd0cc635 Release 1.1.5 2022-09-16 23:27:55 +12:00
Ralph Slooten
a372e8150e Update README 2022-09-16 23:15:40 +12:00
Ralph Slooten
2bc2660ad5 Fix count of selected messages 2022-09-16 21:54:25 +12:00
Ralph Slooten
5d6aa7c48a UI: Support for inline images using filenames instead of cid
Some historic email programs use the attachment filename instead of a reference cid for inline images (eg: Outlook).
2022-09-16 18:40:29 +12:00
Ralph Slooten
997e041042 Build: Switch to esbuild-sass-plugin 2022-09-16 14:59:28 +12:00
Ralph Slooten
5c362c1430 Merge tag '1.1.4' into develop
Release 1.1.4
2022-09-15 21:54:19 +12:00
Ralph Slooten
9219b2d411 Merge branch 'release/1.1.4' 2022-09-15 21:54:16 +12:00
Ralph Slooten
86abc7ea68 Release 1.1.4 2022-09-15 21:54:16 +12:00
Ralph Slooten
867dbf41d5 UI: Minor UI color change & unread count position adjustment 2022-09-15 21:52:22 +12:00
Ralph Slooten
51e458ad57 Security: Add restrictive HTTP Content-Security-Policy 2022-09-15 21:23:27 +12:00
Ralph Slooten
d29a7d6218 Update README 2022-09-15 17:40:39 +12:00
Ralph Slooten
f6a8de3215 UI: Add favicon unread message counter 2022-09-14 22:37:47 +12:00
Ralph Slooten
4e2e59ec87 Update README 2022-09-14 17:25:56 +12:00
Ralph Slooten
6aeebb9824 UI: Remove left & right borders (message list) 2022-09-14 17:14:36 +12:00
Ralph Slooten
a426f64795 Feature: Add --quiet flag to display only errors 2022-09-14 17:14:26 +12:00
Ralph Slooten
b228c9477e Merge branch 'release/1.1.3' 2022-09-14 16:46:50 +12:00
Ralph Slooten
d70f2fd196 Release 1.1.3 2022-09-14 16:46:50 +12:00
Ralph Slooten
0da89d91dd Fix: Update message download link 2022-09-14 16:45:23 +12:00
Ralph Slooten
edab9e1b6b Merge tag '1.1.2' into develop
Release 1.1.2
2022-09-14 13:44:25 +12:00
Ralph Slooten
66aead387e Merge branch 'release/1.1.2' 2022-09-14 13:44:23 +12:00
Ralph Slooten
efe1ac732e Release 1.1.2 2022-09-14 13:44:23 +12:00
Ralph Slooten
33dcd489eb UI: Allow reverse proxy subdirectories 2022-09-14 13:43:38 +12:00
Ralph Slooten
6b2e5b2e41 mod tidy 2022-09-14 13:42:13 +12:00
Ralph Slooten
812c9b99d1 Update installation instructions 2022-09-14 12:11:52 +12:00
Ralph Slooten
8202c94a43 Merge tag '1.1.1' into develop
Release 1.1.1
2022-09-12 22:12:54 +12:00
Ralph Slooten
c1d4a73440 Merge branch 'release/1.1.1' 2022-09-12 22:12:51 +12:00
Ralph Slooten
8e100ff21b Release 1.1.1 2022-09-12 22:12:51 +12:00
Ralph Slooten
088b772de5 UI: Attachment icons and image thumbnails 2022-09-12 22:11:51 +12:00
Ralph Slooten
faf8bd4a08 Merge tag '1.1.0' into develop
Release 1.1.0
2022-09-10 00:00:42 +12:00
Ralph Slooten
0e83a5a985 Merge branch 'release/1.1.0' 2022-09-10 00:00:36 +12:00
Ralph Slooten
3ee91eb6c8 Release 1.1.0 2022-09-10 00:00:36 +12:00
Ralph Slooten
5cd0a6e2f3 UI tweaks 2022-09-09 23:57:53 +12:00
Ralph Slooten
fea733a43e UI: HTML source & highlighting 2022-09-09 23:34:35 +12:00
Ralph Slooten
d4e520772e Remove redundant npm dependency ('remove') 2022-09-04 22:04:26 +12:00
Ralph Slooten
e4a7212f89 Reload UI on prev/next message 2022-09-03 23:02:10 +12:00
Ralph Slooten
e6a5fceedd UI: Add previous/next message links 2022-09-03 22:46:38 +12:00
Ralph Slooten
bf4d5fbc6b Update changelog format 2022-09-03 19:20:51 +12:00
Ralph Slooten
93c3dec66e Merge tag '1.0.0' into develop
Release 1.0.0
2022-09-03 19:13:46 +12:00
Ralph Slooten
98026e0685 Merge branch 'release/1.0.0' 2022-09-03 19:13:42 +12:00
Ralph Slooten
ecd3a97853 Release 1.0.0 2022-09-03 19:13:20 +12:00
Ralph Slooten
695270e515 Merge branch 'feature/multi-selection' into develop 2022-09-03 19:09:57 +12:00
Ralph Slooten
43403bc6f7 Feature: Multiple message selection for group actions using shift/ctrl click
Allow group actions for deleting & marking as read/unread, resolves #11
2022-09-03 19:01:54 +12:00
Ralph Slooten
6dbdbf1637 UI: Post data using 'application/json' 2022-09-03 19:01:54 +12:00
Ralph Slooten
3c81e152e6 UI: Display unknown recipients as as Undisclosed recipients 2022-09-03 19:01:53 +12:00
Ralph Slooten
9501b460c5 Update README 2022-09-03 19:01:53 +12:00
Ralph Slooten
6233cb1e07 UI: Update frontend modules & esbuild 2022-09-03 19:01:53 +12:00
Ralph Slooten
f64f377199 Feature: Search parser improvements 2022-09-03 19:01:34 +12:00
Ralph Slooten
f872424526 UI: Update frontend modules & esbuild 2022-09-01 22:01:56 +12:00
Ralph Slooten
5d530edfab feature: Search parser improvements 2022-09-01 21:45:35 +12:00
Ralph Slooten
12c54f4bb3 Update changelog format 2022-08-30 23:52:53 +12:00
Ralph Slooten
23e47c567a Merge tag '1.0.0-beta1' into develop
Release 1.0.0-beta1
2022-08-30 23:15:36 +12:00
Ralph Slooten
b6940eccff Merge branch 'release/1.0.0-beta1' 2022-08-30 23:15:31 +12:00
Ralph Slooten
eb796924b1 Merge branch 'feature/sqlite' into develop 2022-08-30 23:10:25 +12:00
Ralph Slooten
54ba59872e Deprecate --data flag (replaced by --db-file) 2022-08-30 23:02:56 +12:00
Ralph Slooten
eff483c1c4 feature: Switch backend storage to use SQLite
BREAKING CHANGE: This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
2022-08-30 22:42:43 +12:00
Ralph Slooten
9f5d329105 Update CHANGELOG format 2022-08-29 22:44:32 +12:00
Ralph Slooten
77e6b88c5d UI: Resize preview iframe on load 2022-08-29 22:22:07 +12:00
Ralph Slooten
5a9fd0686e Update README 2022-08-18 21:41:37 +12:00
Ralph Slooten
3054dfe79e Catch error in testing 2022-08-18 21:28:46 +12:00
Ralph Slooten
40cb76810e Merge tag '0.1.5' into develop
Release 0.1.5
2022-08-16 08:17:24 +12:00
Ralph Slooten
8b6b6640d5 Merge branch 'release/0.1.5' 2022-08-16 08:17:21 +12:00
Ralph Slooten
a8945bd303 Release 0.1.5 2022-08-16 08:17:20 +12:00
Ralph Slooten
53e199b20f Better error handling on failed upgrade if file corrupt 2022-08-16 08:16:03 +12:00
Ralph Slooten
a6693481fa Quote exact string matches in search test 2022-08-12 10:19:49 +12:00
Ralph Slooten
1aa58eeaaf Feature: Improved message search - any order & phrase quoting 2022-08-12 10:16:21 +12:00
Ralph Slooten
133b36c34c UI: Change breakpoints for mobile view of messages 2022-08-11 00:32:10 +12:00
Ralph Slooten
ed28a4cc0d UI: Resize iframes with viewport resize 2022-08-11 00:31:22 +12:00
Ralph Slooten
bc30b012cf Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:32:02 +12:00
Ralph Slooten
2ae51c3f64 Merge branch 'release/0.1.4' 2022-08-10 20:31:59 +12:00
Ralph Slooten
b6a87b9410 Release 0.1.4 2022-08-10 20:31:59 +12:00
Ralph Slooten
1f7dd0287a Merge branch 'feature/ui-tweaks' into develop 2022-08-10 20:31:25 +12:00
Ralph Slooten
f33cbce63f Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:30:05 +12:00
Ralph Slooten
79b6892320 Merge branch 'release/0.1.4' 2022-08-10 20:30:02 +12:00
Ralph Slooten
799987ecb1 Release 0.1.4 2022-08-10 20:30:01 +12:00
Ralph Slooten
2d57839b3e UI: Mobile compatibility improvements & functionality 2022-08-10 20:21:27 +12:00
Ralph Slooten
86cc237c78 Feature: Email compression in storage
Reduces storage requirements +-25% & speeds up database read & writes by between 25-33%, depending on email content (attachments).
2022-08-10 14:33:16 +12:00
Ralph Slooten
cc15ada304 Testing: Enable testing on feature branches 2022-08-10 09:48:06 +12:00
Ralph Slooten
49bc62f0aa Update screenshot 2022-08-08 23:16:48 +12:00
Ralph Slooten
444b65d371 Testing: Database total/unread statistics tests 2022-08-07 23:07:36 +12:00
Ralph Slooten
15859f7be9 Add Go Report Card 2022-08-07 22:38:23 +12:00
Ralph Slooten
486388a798 Fix typos 2022-08-07 22:35:42 +12:00
Ralph Slooten
9ab28d606a Add privacy classes for screenshots 2022-08-07 13:38:53 +12:00
Ralph Slooten
18b5ce8c18 Add build status to README 2022-08-07 10:57:45 +12:00
Ralph Slooten
93d5289d25 Merge tag '0.1.3' into develop
Release 0.1.3
2022-08-07 10:41:02 +12:00
Ralph Slooten
97bf9c257c Merge branch 'release/0.1.3' 2022-08-07 10:40:59 +12:00
Ralph Slooten
18b0f5b790 Release 0.1.3 2022-08-07 10:40:59 +12:00
Ralph Slooten
94feb2ccaa Update screenshot 2022-08-07 10:38:40 +12:00
Ralph Slooten
aba3c46eb1 Update wording for "no emails/results message" 2022-08-07 10:28:33 +12:00
Ralph Slooten
c9c910ab7c UI: Better error handling when connection to server is broken 2022-08-07 10:21:08 +12:00
Ralph Slooten
29c7295d16 Merge branch 'feature/ui-tweaks' into develop 2022-08-07 10:14:40 +12:00
Ralph Slooten
61e15e4155 UI: Add reset search button 2022-08-07 10:11:48 +12:00
Ralph Slooten
e03618570d UI: Minor UI tweaks 2022-08-07 10:11:21 +12:00
Ralph Slooten
d4cf95363f Feature: Mark all messages as read 2022-08-07 09:34:06 +12:00
Ralph Slooten
f260495495 UI: Update pagination values when new mail arrives when not on first page 2022-08-07 08:38:52 +12:00
Ralph Slooten
d9f1f88107 Merge pull request #6 from KaptinLin/develop
Bugfix: Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables
2022-08-07 08:12:48 +12:00
KaptinLin
09b704bcd7 Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables 2022-08-06 22:34:33 +08:00
Ralph Slooten
a14cdce07f Update disconnected state hover title 2022-08-07 01:15:40 +12:00
Ralph Slooten
9fc5318e86 Merge tag '0.1.2' into develop
Release 0.1.2
2022-08-07 01:08:03 +12:00
Ralph Slooten
8affa0f375 Merge branch 'release/0.1.2' 2022-08-07 01:07:58 +12:00
Ralph Slooten
cf8994ceaf Release 0.1.2 2022-08-07 01:07:58 +12:00
Ralph Slooten
39132723db Update README 2022-08-07 01:07:04 +12:00
Ralph Slooten
642487742c Feature: Optional browser notifications (HTTPS only) 2022-08-07 01:04:55 +12:00
Ralph Slooten
544f0175d9 Security: Don't allow tar files containing a ".." 2022-08-07 00:26:18 +12:00
Ralph Slooten
788e390e01 Ignore http.RsponseWriter errors 2022-08-07 00:09:32 +12:00
Ralph Slooten
f6ae6bbdbb Merge branch 'feature/security' into develop 2022-08-06 23:55:36 +12:00
Ralph Slooten
1155443785 Security: Sanitize mailbox names 2022-08-06 23:54:34 +12:00
Ralph Slooten
056bef7d5e Security: Use strconv.Atoi() for safe string to int conversions 2022-08-06 23:54:19 +12:00
Ralph Slooten
37eec298d7 0.1.1 2022-08-06 23:11:55 +12:00
Ralph Slooten
a77b532328 Merge tag '0.1.1' into develop
Merge
2022-08-06 23:11:12 +12:00
Ralph Slooten
00d6463de1 Merge branch 'hotfix/0.1.1' 2022-08-06 23:08:49 +12:00
Ralph Slooten
a3b92711a9 Bugfix: Fix env variable for MP_UI_SSL_KEY 2022-08-06 23:08:34 +12:00
Ralph Slooten
ba8c4cd2aa Merge tag '0.1.0' into develop
Release 0.1.0
2022-08-06 20:01:50 +12:00
Ralph Slooten
ec5267f5a5 Merge branch 'release/0.1.0' 2022-08-06 20:01:45 +12:00
Ralph Slooten
73d2b1ba93 Release 0.1.0 2022-08-06 20:01:45 +12:00
Ralph Slooten
56fdaa1224 Feature: SMTP STARTTLS & SMTP authentication support
Resolves #4
2022-08-06 20:00:05 +12:00
Ralph Slooten
25090aeb2a Create codeql-analysis.yml 2022-08-06 00:29:42 +12:00
Ralph Slooten
9bc8d005fb Merge tag '0.0.9' into develop
Release 0.0.9
2022-08-06 00:12:19 +12:00
Ralph Slooten
b57e340389 Merge branch 'release/0.0.9' 2022-08-06 00:12:10 +12:00
Ralph Slooten
b9043b6c39 Release 0.0.9 2022-08-06 00:12:09 +12:00
Ralph Slooten
5860171002 Feature: HTTPS option for web UI 2022-08-06 00:09:20 +12:00
Ralph Slooten
ad49bf2898 Bugfix: Include read status in search results 2022-08-05 23:04:14 +12:00
Ralph Slooten
2d221a6b67 Testing: Memory & physical database tests 2022-08-05 21:35:57 +12:00
Ralph Slooten
4f266cd3f3 Merge tag '0.0.8' into develop
Release 0.0.8
2022-08-05 16:17:17 +12:00
138 changed files with 21516 additions and 4357 deletions

View File

@@ -2,7 +2,6 @@
Notable changes to Mailpit will be documented in this file.
{{ if .Versions -}}
{{ if .Unreleased.CommitGroups -}}
## [Unreleased]
@@ -20,7 +19,17 @@ Notable changes to Mailpit will be documented in this file.
{{ range .Versions }}
{{- if .CommitGroups -}}
## {{ .Tag.Name }}
## [{{ .Tag.Name }}]
{{ if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end -}}
{{ end }}
{{ end -}}
{{ end -}}
{{ range .CommitGroups -}}
### {{ .Title }}
@@ -28,21 +37,11 @@ Notable changes to Mailpit will be documented in this file.
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end }}
{{ end -}}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
- {{ .Header }}
{{ end }}
{{ end -}}
{{- if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [axllent]

23
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "docker"
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -8,30 +8,37 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Parse semver
id: semver_parser
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
with:
input_string: '${{ github.ref_name }}'
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ steps.tag.outputs.tag }}"
"VERSION=${{ github.ref_name }}"
push: true
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}
tags: |
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}

View File

@@ -0,0 +1,23 @@
name: Close stale issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v8.0.0
with:
days-before-issue-stale: 21
days-before-issue-close: 7
exempt-issue-labels: "enhancement,bug,javascript,docker"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 21 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

72
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "develop" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "develop" ]
schedule:
- cron: '34 23 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -10,29 +10,30 @@ jobs:
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: ["386", amd64, arm64]
goarch: ["386", amd64, arm, arm64]
exclude:
- goarch: "386"
goos: darwin
- goarch: arm64
- goarch: "386"
goos: windows
- goarch: arm
goos: darwin
- goarch: arm
goos: windows
steps:
- uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- uses: actions/checkout@v4
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: 'npm'
- run: echo "Building assets for ${{ github.ref_name }}"
- run: npm install
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.30
- uses: wangyoucao577/go-release-action@v1.40
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
@@ -42,4 +43,6 @@ jobs:
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
extra_files: LICENSE README.md
md5sum: false
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"
overwrite: true
retry: 5
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"

View File

@@ -1,21 +1,21 @@
name: Test
name: Tests
on:
pull_request:
branches: [ develop ]
branches: [ develop, 'feature/**' ]
push:
branches: [ develop ]
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
matrix:
go-version: [1.18.x]
go-version: [1.21.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: |
@@ -24,13 +24,13 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./storage -v
- run: go test ./storage -bench=.
- run: go test ./internal/storage ./server ./internal/tools -v
- run: go test ./internal/storage -bench=.
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: 'npm'
- run: npm install
- run: npm run package

5
.gitignore vendored
View File

@@ -1,6 +1,9 @@
/node_modules/
/send
/sendmail/sendmail
/server/ui/dist
/Makefile
/mailpit
/mailpit*
/.idea
*.old
*.db

View File

@@ -2,8 +2,856 @@
Notable changes to Mailpit will be documented in this file.
## [v1.9.6]
## 0.0.8
### Libs
- Update node modules
- Update Go modules
### UI
- Display message previews on separate line ([#175](https://github.com/axllent/mailpit/issues/175))
## [v1.9.5]
### Feature
- Add `reindex` subcommand to reindex all messages
- Display email previews ([#175](https://github.com/axllent/mailpit/issues/175))
### Fix
- HTML message preview background color when switching themes in Chrome
- Correctly detect tags in search (UI)
### Tests
- Add message summary tests
- Add snippet tests
## [v1.9.4]
### Chore
- Remove some flags deprecated 08/2022
### Feature
- Set auth credentials directly from environment variables
### Libs
- Update node modules
- Update Go modules
### UI
- Add option to delete a message after release
## [v1.9.3]
### Chore
- Update internal/storage import paths
- Move storage package to internal/storage
- Update internal import paths
- Move utils/* packages to internal/*
### Testing
- Add endpoints for integration tests
### Tests
- Add more API tests
- Add tests for ArgsParser & CleanTag
### UI
- Do not show excluded search tags as "current" in nav
- Display "Loading messages" instead of "No results" while loading results
- Only queue broadcast events if clients are connected
## [v1.9.2]
### Fix
- Delete all messages matching search when more than 1000 results
### Libs
- Update node modules
### Tests
- Add message tag tests
- Add search delete tests
### UI
- Reset pagination when returning to inbox from search
## [v1.9.1]
### Chore
- Update caniemail data
### Libs
- Update Go modules
### UI
- Set 404 page when loading a non-existent message
- Link email addresses in message summary to search
- Better support for mobile screen sizes
## [v1.9.0]
### API
- Remove redundant `Read` status from message (always true)
- Delete by search filter
- Add endpoint to return all tags in use
### Feature
- Improved search parser
- New search filter `[!]is:tagged`
### Fix
- Correctly escape certain characters in search (eg: `'`)
### Libs
- Update minimum Go version to 1.20
- Update Go modules
- Update node modules
### Tests
- Bump Go version to 1.21
### UI
- Rewrite web UI, add URL routing and components
## [v1.8.4]
### Fix
- Correctly decode proxy links containing HTML entities (screenshots)
## [v1.8.3]
### Feature
- HTML screenshots
### Libs
- Update node modules
### UI
- Group message tabs on mobile
## [v1.8.2]
### Build
- Update wangyoucao577/go-release-action[@v1](https://github.com/v1).39
### Feature
- Link check to test message links
- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
### Libs
- Update Go libs
### UI
- Set hostname in page meta title to identify Mailpit instance
## [v1.8.1]
### Docs
- 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
- Display clickable toast notifications for new messages
## [v1.6.2]
### Bugfix
- If set use return-path address as SMTP from address
## [v1.6.1]
### Bugfix
- Add API release route again (bad merge)
## [v1.6.0]
### API
- Enable cross-origin resource sharing (CORS) configuration
- Message relay / release
- Include Return-Path in message summary data
### Feature
- Inject/update Bcc header for missing addresses when SMTP recipients do not match messsage headers
### Libs
- Update Go modules
- Update node modules
### UI
- Display Return-Path if different to the From address
- Message release functionality
## [v1.5.5]
### Docker
- Add Docker image tag for major/minor version
### Feature
- Update listen regex to allow IPv6 addresses ([#85](https://github.com/axllent/mailpit/issues/85))
## [v1.5.4]
### Feature
- Mobile and tablet HTML preview toggle in desktop mode
## [v1.5.3]
### Bugfix
- Enable SMTP auth flags to be set via env
## [v1.5.2]
### API
- Include Reply-To in message summary (including Web UI)
### UI
- Tab to view formatted message headers
## [v1.5.1]
### Feature
- Add 'o', 'b' & 's' ignored flags for sendmail
### Libs
- Update Go modules
- Update node modules
## [v1.5.0]
### API
- Return received datetime when message does not contain a date header
### Bugfix
- Fix JavaScript error when adding the first tag manually
### Feature
- OpenAPI / Swagger schema
- Download raw message, HTML/text body parts or attachments via single button
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.4.0]
### API
- Return received datetime when message does not contain a date header
### Feature
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.3.11]
### Docker
- Expose default ports (1025/tcp 8025/tcp)
### Feature
- Expand custom webroot path to include a-z A-Z 0-9 _ . - and /
## [v1.3.10]
### Bugfix
- Fix search with existing emails
### Libs
- Update node modules
## [v1.3.9]
### Feature
- Add Cc and Bcc search filters
### Libs
- Update node modules
- Update Go modules
### Pull Requests
- Merge pull request [#44](https://github.com/axllent/mailpit/issues/44) from axllent/dependabot/github_actions/wangyoucao577/go-release-action-1.36
- Merge pull request [#43](https://github.com/axllent/mailpit/issues/43) from axllent/dependabot/github_actions/docker/build-push-action-4
- Merge pull request [#55](https://github.com/axllent/mailpit/issues/55) from axllent/dependabot/go_modules/golang.org/x/image-0.5.0
- Merge pull request [#42](https://github.com/axllent/mailpit/issues/42) from shizunge/dependabot
## [v1.3.8]
### Bugfix
- Restore notification icon
### UI
- Compress SVG icons
## [v1.3.7]
### Feature
- Add Kubernetes API health (livez/readyz) endpoints
### Libs
- Upgrade to esbuild 0.17.5
## [v1.3.6]
### Bugfix
- Correctly index missing 'From' header in database
### Libs
- Update node modules
- Update go modules
## [v1.3.5]
### Bugfix
- Include HTML link text in search data
## [v1.3.4]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.3]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.2]
### Build
- Temporarily disable arm (32) Docker build
## [v1.3.1]
### Bugfix
- Append trailing slash to custom webroot for UI & API
### Libs
- Upgrade esbuild & axios
### UI
- Rename "results" to "result" when singular message returned
## [v1.3.0]
### Build
- Remove duplicate bootstrap CSS
### Libs
- Update go modules
- Update node modules
## [v1.2.9]
### Bugfix
- Delay 200ms to set `target="_blank"` for all rendered email links
## [v1.2.8]
### Bugfix
- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
### Feature
- Message tags and auto-tagging
## [v1.2.7]
### Feature
- Allow custom webroot
## [v1.2.6]
### API
- Provide structs of API v1 responses for use in client code
### Libs
- Update go modules
- Update node modules
## [1.2.5]
### UI
- Broadcast "delete all" action to reload all connected clients
- Load first page if paginated list returns 0 results
- Theme changes
- Bump build action to use node 18
## [1.2.4]
### Bugfix
- Fix mail download link
## [1.2.3]
### API
- Add limit and start parameters to search
### UI
- Prevent double message index request on websocket connect
## [1.2.2]
### API
- Add API endpoint to return message headers
### Libs
- Update go modules
### Testing
- Add API test for raw & message headers
## [1.2.1]
### UI
- Update frontend modules
- Add about app modal with version update notification
## [1.2.0]
### Feature
- Add REST API
### Testing
- Add API tests
### UI
- Changes to use new data API
- Hide delete all / mark all read in message view
## [1.1.7]
### Fix
- Normalize running binary name detection (Windows)
## [1.1.6]
### Fix
- Workaround for Safari source matching bug blocking event listener
### UI
- Add documentation link (wiki)
## [1.1.5]
### Build
- Switch to esbuild-sass-plugin
### UI
- Support for inline images using filenames instead of cid
## [1.1.4]
### Feature
- Add --quiet flag to display only errors
### Security
- Add restrictive HTTP Content-Security-Policy
### UI
- Minor UI color change & unread count position adjustment
- Add favicon unread message counter
- Remove left & right borders (message list)
## [1.1.3]
### Fix
- Update message download link
## [1.1.2]
### UI
- Allow reverse proxy subdirectories
## [1.1.1]
### UI
- Attachment icons and image thumbnails
## [1.1.0]
### UI
- HTML source & highlighting
- Add previous/next message links
## [1.0.0]
### Feature
- Multiple message selection for group actions using shift/ctrl click
- Search parser improvements
### Feature
- Search parser improvements
### UI
- Post data using 'application/json'
- Display unknown recipients as as `Undisclosed recipients`
- Update frontend modules & esbuild
- Update frontend modules & esbuild
## [1.0.0-beta1]
### BREAKING CHANGE
This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
### Feature
- Switch backend storage to use SQLite
### UI
- Resize preview iframe on load
## [0.1.5]
### Feature
- Improved message search - any order & phrase quoting
### UI
- Change breakpoints for mobile view of messages
- Resize iframes with viewport resize
## [0.1.4]
### Feature
- Email compression in storage
### Testing
- Enable testing on feature branches
- Database total/unread statistics tests
### UI
- Mobile compatibility improvements & functionality
## [0.1.3]
### Feature
- Mark all messages as read
### UI
- Better error handling when connection to server is broken
- Add reset search button
- Minor UI tweaks
- Update pagination values when new mail arrives when not on first page
### Pull Requests
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
## [0.1.2]
### Feature
- Optional browser notifications (HTTPS only)
### Security
- Don't allow tar files containing a ".."
- Sanitize mailbox names
- Use strconv.Atoi() for safe string to int conversions
## [0.1.1]
### Bugfix
- Fix env variable for MP_UI_SSL_KEY
## [0.1.0]
### Feature
- SMTP STARTTLS & SMTP authentication support
## [0.0.9]
### Bugfix
- Include read status in search results
### Feature
- HTTPS option for web UI
### Testing
- Memory & physical database tests
## [0.0.8]
### Bugfix
- Fix total/unread count after failed message inserts
@@ -12,25 +860,25 @@ Notable changes to Mailpit will be documented in this file.
- Add project links to help in CLI
## 0.0.7
## [0.0.7]
### Bugfix
- Command flag should be `--auth-file`
## 0.0.6
## [0.0.6]
### Bugfix
- Disable CGO when building multi-arch binaries
## 0.0.5
## [0.0.5]
### Feature
- Basic authentication support
## 0.0.4
## [0.0.4]
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
@@ -47,15 +895,16 @@ Notable changes to Mailpit will be documented in this file.
- cater for messages without From email address
## 0.0.3
## [0.0.3]
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
## 0.0.2
## [0.0.2]
### Feature
- Unread statistics

View File

@@ -8,8 +8,7 @@ WORKDIR /app
RUN apk add --no-cache git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
FROM alpine:latest
@@ -17,4 +16,6 @@ COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
EXPOSE 1025/tcp 8025/tcp
ENTRYPOINT ["/mailpit"]

112
README.md
View File

@@ -1,60 +1,92 @@
# Mailpit
# Mailpit - email testing for developers
Mailpit is an email testing tool for developers.
![Tests](https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg)
![Build status](https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg)
![Docker builds](https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg)
![CodeQL](https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/axllent/mailpit)](https://goreportcard.com/report/github.com/axllent/mailpit)
It acts as both an SMTP server, and provides a web interface to view all captured emails.
Mailpit is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
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 was originally **inspired** by MailHog which is now [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development for a few years now.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/docs/screenshot.png)
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
## Features
- Runs completely on a single binary
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
- 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 basic authentication for web UI (see [wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
- 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)
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
- Can handle tens of thousands of emails
- Multi-arch [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
## Planned features
- Optional HTTPS for web UI
- Browser notifications for new mail (HTTPS only)
- 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 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))
- A simple REST API ([see docs](docs/apiv1/README.md))
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
## Installation
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
Mailpit runs as a single binary and can be installed in different ways:
### Install via Brew (Mac)
Install Mailpit with `brew install mailpit`.
### Install via bash script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
### Download static binary (Windows, Linux and Mac)
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can extracted and copied to your `$PATH`, or simply run as `./mailpit`.
### Docker
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images) for 386, amd64 & arm64 images.
### Compile from source
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 severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying 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) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, 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).

40
cmd/reindex.go Normal file
View File

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

View File

@@ -1,14 +1,18 @@
// Package cmd is the main application
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/server/smtpd"
"github.com/spf13/cobra"
)
@@ -71,10 +75,60 @@ func init() {
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// defaults from envars if provided
if len(os.Getenv("MP_DATA_DIR")) > 0 {
config.DataDir = os.Getenv("MP_DATA_DIR")
}
// load and warn deprecated ENV vars
initDeprecatedConfigFromEnv()
// load ENV vars
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
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 & API authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
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")
// deprecated flags 2023/03/12
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-ssl-key", config.SMTPTLSKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().Lookup("ui-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ui-ssl-key").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-key").Deprecated = "use --ui-tls-key"
rootCmd.Flags().Lookup("smtp-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
}
// Load settings from environment
func initConfigFromEnv() {
// inherit from environment if provided
config.DataFile = os.Getenv("MP_DATA_FILE")
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
@@ -84,14 +138,94 @@ func init() {
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
config.AuthFile = os.Getenv("MP_AUTH_FILE")
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVarP(&config.AuthFile, "auth-file", "a", config.AuthFile, "A password file for authentication (see wiki)")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// UI
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
auth.SetUIAuth(os.Getenv("MP_UI_AUTH"))
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
// SMTP
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH"))
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
config.SMTPStrictRFCHeaders = true
}
// Relay server config
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAllIncoming = true
}
// Misc options
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
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
}
if getEnabledFromEnv("MP_VERBOSE") {
logger.VerboseLogging = true
}
}
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
}
}
// Wrapper to get a boolean from an environment variable
func getEnabledFromEnv(k string) bool {
if len(os.Getenv(k)) > 0 {
v := strings.ToLower(os.Getenv(k))
return v == "1" || v == "true" || v == "yes"
}
return false
}

View File

@@ -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",
Short: "A sendmail command replacement",
Long: `A sendmail command replacement.
You can optionally create a symlink called 'sendmail' to the main binary.`,
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
}
@@ -25,9 +20,17 @@ You can optionally create a symlink called 'sendmail' to the main binary.`,
func init() {
rootCmd.AddCommand(sendmailCmd)
// these are simply repeated for cli consistency
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", "", "SMTP sender")
sendmailCmd.Flags().BoolP("long-i", "i", 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")
}

View File

@@ -5,21 +5,11 @@ import (
"os"
"runtime"
"github.com/axllent/mailpit/updater"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/updater"
"github.com/spf13/cobra"
)
var (
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
@@ -36,10 +26,10 @@ var versionCmd = &cobra.Command{
}
fmt.Printf("%s %s compiled with %s on %s/%s\n",
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
if err == nil && updater.GreaterThan(latest, Version) {
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil && updater.GreaterThan(latest, config.Version) {
fmt.Printf(
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
latest,
@@ -59,7 +49,7 @@ func init() {
}
func updateApp() error {
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
if err != nil {
return err
}

View File

@@ -1,46 +1,149 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/tg123/go-htpasswd"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
var (
// SMTPListen to listen on <interface>:<port>
SMTPListen = "0.0.0.0:1025"
SMTPListen = "[::]:1025"
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
HTTPListen = "[::]:8025"
// DataDir for mail (optional)
DataDir string
// DataFile for mail (optional)
DataFile string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// VerboseLogging for console output
VerboseLogging = false
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
// NoLogging for tests
NoLogging = false
// UITLSCert file
UITLSCert string
// SSLCert @TODO
SSLCert string
// SSLKey @TODO
SSLKey string
// UITLSKey file
UITLSKey string
// AuthFile for basic authentication
AuthFile string
// UIAuthFile for UI & API authentication
UIAuthFile string
// Auth used for euthentication
Auth *htpasswd.File
// Webroot to define the base path for the UI and API
Webroot = "/"
// SMTPTLSCert file
SMTPTLSCert string
// SMTPTLSKey file
SMTPTLSKey string
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
// 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
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
// 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 pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAllIncoming = false
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
ContentSecurityPolicy string
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
Match string
}
// 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, 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 {
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
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")
}
re := regexp.MustCompile(`^((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(\[([\da-fA-F:])+\])):\d+$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
}
@@ -48,13 +151,201 @@ func VerifyConfig() error {
return errors.New("HTTP bind should be in the format of <ip>:<port>")
}
if AuthFile != "" {
a, err := htpasswd.New(AuthFile, htpasswd.DefaultSystems, nil)
if UIAuthFile != "" {
if !isFile(UIAuthFile) {
return fmt.Errorf("HTTP password file not found: %s", UIAuthFile)
}
b, err := os.ReadFile(UIAuthFile)
if err != nil {
return err
}
Auth = a
if err := auth.SetUIAuth(string(b)); err != nil {
return err
}
}
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
return errors.New("You must provide both a UI TLS certificate and a key")
}
if UITLSCert != "" {
if !isFile(UITLSCert) {
return fmt.Errorf("TLS certificate not found: %s", UITLSCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("TLS key not found: %s", UITLSKey)
}
}
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("You must provide both an SMTP TLS certificate and a key")
}
if SMTPTLSCert != "" {
if !isFile(SMTPTLSCert) {
return fmt.Errorf("SMTP TLS certificate not found: %s", SMTPTLSCert)
}
if !isFile(SMTPTLSKey) {
return fmt.Errorf("SMTP TLS key not found: %s", SMTPTLSKey)
}
}
if SMTPAuthFile != "" {
if !isFile(SMTPAuthFile) {
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
b, err := os.ReadFile(SMTPAuthFile)
if err != nil {
return err
}
if err := auth.SetSMTPAuth(string(b)); err != nil {
return err
}
}
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
return errors.New("SMTP authentication cannot use both credentials and --smtp-auth-accept-any")
}
if SMTPTLSCert == "" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.]`)
if validWebrootRe.MatchString(Webroot) {
return fmt.Errorf("Invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - /]", Webroot)
}
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
SMTPTags = []AutoTag{}
if SMTPCLITags != "" {
args := tools.ArgsParser(SMTPCLITags)
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
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:], "=")))
if len(match) == 0 {
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("Error parsing tags (%s)", a)
}
}
}
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
return err
}
if !ReleaseEnabled && SMTPRelayAllIncoming {
return errors.New("SMTP relay config must be set to relay all messages")
}
if SMTPRelayAllIncoming {
// this deserves a warning
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
return nil
}
// Parse & validate the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
if !isFile(c) {
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("SMTP relay host not set")
}
if SMTPRelayConfig.Port == 0 {
SMTPRelayConfig.Port = 25 // default
}
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
SMTPRelayConfig.Auth = "none"
} else if SMTPRelayConfig.Auth == "plain" {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%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 == "" {
return fmt.Errorf("SMTP relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
}
} else {
return fmt.Errorf("SMTP relay authentication method not supported: %s", SMTPRelayConfig.Auth)
}
ReleaseEnabled = true
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
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
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}

View File

@@ -1,24 +0,0 @@
package data
import "time"
// MailboxSummary struct
type MailboxSummary struct {
Name string
Slug string
Total int
Unread int
LastMessage time.Time
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
}

View File

@@ -1,64 +0,0 @@
package data
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message struct for loading messages. It does not include physical attachments.
type Message struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Date time.Time
Created time.Time
Text string
HTML string
Size int
Inline []Attachment
Attachments []Attachment
}
// Attachment struct for inline and attachments
type Attachment struct {
PartID string
FileName string
ContentType string
ContentID string
Size int
}
// Summary struct for frontend messages
type Summary struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Created time.Time
Size int
Attachments int
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
return o
}

116
docs/apiv1/Message.md Normal file
View File

@@ -0,0 +1,116 @@
# Message
## Message summary
Returns a JSON summary of the message and attachments.
**URL** : `api/v1/message/<ID>`
**Method** : `GET`
## Response
**Status** : `200`
```json
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [],
"Bcc": [],
"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,
"Inline": [
{
"PartID": "1.2",
"FileName": "filename.gif",
"ContentType": "image/gif",
"ContentID": "919564503@07092006-1525",
"Size": 7760
}
],
"Attachments": [
{
"PartID": "2",
"FileName": "filename.doc",
"ContentType": "application/msword",
"ContentID": "",
"Size": 43520
}
]
}
```
### Notes
- `From` - Name & Address, or null
- `To`, `CC`, `BCC`, `ReplyTo` - Array of Names & Address
- `Date` - Parsed email local date & time from headers
- `Size` - Total size of raw email
- `Inline`, `Attachments` - Array of attachments and inline images.
---
## Attachments
**URL** : `api/v1/message/<ID>/part/<PartID>`
**Method** : `GET`
Returns the attachment using the MIME type provided by the attachment `ContentType`.
---
## Headers
**URL** : `api/v1/message/<ID>/headers`
**Method** : `GET`
Returns all message headers as a JSON array.
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
```json
{
"Content-Type": [
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
],
"Date": [
"Wed, 12 Jul 2006 23:38:30 +1200"
],
"Delivered-To": [
"user@example.com",
"user-alias@example.com"
],
"From": [
"\"User Name\" \\u003remote@example.com\\u003e"
],
"Message-Id": [
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
],
....
}
```
---
## Raw (source) email
**URL** : `api/v1/message/<ID>/raw`
**Method** : `GET`
Returns the original email source including headers and attachments.

169
docs/apiv1/Messages.md Normal file
View File

@@ -0,0 +1,169 @@
# Messages
List & delete messages.
---
## List
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
**URL** : `api/v1/messages`
**Method** : `GET`
### Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
### Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"messages_count": 50,
"start": 0,
"tags": ["test"],
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Tags": ["test"],
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox
- `unread` - Total unread messages in mailbox
- `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
- `To`, `CC`, `BCC` - Array of Names & Address
- `Created` - Local date & time the message was received
- `Size` - Total size of raw email in bytes
---
## Delete individual messages
Delete one or more messages by ID.
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": ["<ID>","<ID>"...]
}
```
### Response
**Status** : `200`
---
## Delete all messages
Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely).
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": []
}
```
### Response
**Status** : `200`
---
## Update individual read statuses
Set the read status of one or more messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"read": false
}
```
### Response
**Status** : `200`
---
## Update all messages read status
Set the read status of all messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": [],
"read": false
}
```
### Response
**Status** : `200`

14
docs/apiv1/README.md Normal file
View File

@@ -0,0 +1,14 @@
# API v1
Mailpit provides a simple REST API to access and delete stored messages.
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
You can view the Swagger API documentation directly within Mailpit by going to `http://0.0.0.0:8025/api/v1/`.
The API is split into three main parts:
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
- [Message](Message.md) - Return message data & attachments
- [Tags](Tags.md) - Set message tags
- [Search](Search.md) - Searching messages

70
docs/apiv1/Search.md Normal file
View File

@@ -0,0 +1,70 @@
# Search
**URL** : `api/v1/search?query=<string>`
**Method** : `GET`
The search returns the most recent matches (default 50).
Matching messages are returned in the order of latest received to oldest.
## Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| query | string | true | Search query |
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
## Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"messages_count": 25,
"start": 0,
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Test email",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox (all messages, not search)
- `unread` - Total unread messages in mailbox (all messages, not search)
- `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
- `Size` - Total size of raw email in bytes

27
docs/apiv1/Tags.md Normal file
View File

@@ -0,0 +1,27 @@
# Tags
Set message tags.
---
## Update message tags
Set the tags for one or more messages.
If the tags array is empty then all tags are removed from the messages.
**URL** : `api/v1/tags`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"tags": ["<tag>","<tag>"]
}
```
### Response
**Status** : `200`

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -1,22 +0,0 @@
const { build } = require('esbuild')
const pluginVue = require('esbuild-plugin-vue-next')
const sassPlugin = require("esbuild-plugin-sass");
const doWatch = process.env.WATCH == 'true' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;
build({
entryPoints: ["server/ui-src/app.js"],
bundle: true,
watch: doWatch,
minify: doMinify,
sourcemap: false,
outfile: "server/ui/dist/app.js",
plugins: [pluginVue(), sassPlugin()],
loader: {
".svg": "file",
".woff": "file",
".woff2": "file",
},
logLevel: "info"
})

37
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import * as esbuild from 'esbuild'
import pluginVue from 'esbuild-plugin-vue-next'
import { sassPlugin } from 'esbuild-sass-plugin'
const doWatch = process.env.WATCH == 'true' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;
const ctx = await esbuild.context(
{
entryPoints: [
"server/ui-src/app.js",
"server/ui-src/docs.js"
],
bundle: true,
minify: doMinify,
sourcemap: false,
define: {
'__VUE_OPTIONS_API__': 'true',
'__VUE_PROD_DEVTOOLS__': 'false',
},
outdir: "server/ui/dist/",
plugins: [pluginVue(), sassPlugin()],
loader: {
".svg": "file",
".woff": "file",
".woff2": "file",
},
logLevel: "info"
}
)
if (doWatch) {
await ctx.watch()
} else {
await ctx.rebuild()
ctx.dispose()
}

89
go.mod
View File

@@ -1,55 +1,72 @@
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.10.0
github.com/k3a/html2text v1.0.8
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/mhale/smtpd v0.8.0
github.com/ostafen/clover/v2 v2.0.0-alpha.2
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/microcosm-cc/bluemonday v1.0.25
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.0
github.com/tg123/go-htpasswd v1.2.1
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.16.0
golang.org/x/text v0.13.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.26.0
)
require (
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
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/aymerick/douceur v0.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // 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/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.6+incompatible // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/orderedcode v0.0.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/klauspost/compress v1.15.9 // 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-runewidth v0.0.13 // 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/rivo/uniseg v0.3.1 // indirect
github.com/satori/go.uuid v1.2.0 // 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/stretchr/testify v1.7.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b // indirect
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/tools v0.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.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.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

381
go.sum
View File

@@ -1,296 +1,251 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/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/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/go-test/deep v1.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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw=
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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.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=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.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.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
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.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.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
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.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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/ostafen/clover/v2 v2.0.0-alpha.2 h1:PgOWohvpj4qNCyASJ7Q8Ke8ld/wsoi+dQJ05b1ebwus=
github.com/ostafen/clover/v2 v2.0.0-alpha.2/go.mod h1:7UyIG46NglzTDRKB4LJiS/enXpuo67Lj05eM8mdhL6M=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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=
github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
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=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b h1:3ogNYyK4oIQdIKzTu68hQrr4iuVxF3AxKl9Aj/eDrw0=
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.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.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/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-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20210124154548-22da62e12c0c/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-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 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=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

98
install.sh Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
GH_REPO="axllent/mailpit"
TIMEOUT=90
set -e
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ $? -ne 0 ]; then
echo -ne "\nThere was an error trying to check what is the latest version of Mailpit.\nPlease try again later.\n"
exit 1
fi
# detect the platform
OS="$(uname)"
case $OS in
Linux)
OS='linux'
;;
FreeBSD)
OS='freebsd'
echo 'OS not supported'
exit 2
;;
NetBSD)
OS='netbsd'
echo 'OS not supported'
exit 2
;;
OpenBSD)
OS='openbsd'
echo 'OS not supported'
exit 2
;;
Darwin)
OS='darwin'
;;
SunOS)
OS='solaris'
echo 'OS not supported'
exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
# detect the arch
OS_type="$(uname -m)"
case "$OS_type" in
x86_64 | amd64)
OS_type='amd64'
;;
i?86 | x86)
OS_type='386'
;;
aarch64 | arm64)
OS_type='arm64'
;;
*)
echo 'OS type not supported'
exit 2
;;
esac
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
#create tmp directory and move to it with macOS compatibility fallback
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
cd "$tmp_dir"
echo "Downloading Mailpit $VERSION"
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
echo "Error downloading"
exit 2
}
mkdir -p /usr/local/bin || exit 2
cp mailpit /usr/local/bin/ || exit 2
chmod 755 /usr/local/bin/mailpit || exit 2
case "$OS" in
'linux')
chown root:root /usr/local/bin/mailpit || exit 2
;;
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
chown root:wheel /usr/local/bin/mailpit || exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
rm -rf "$tmp_dir"
echo "Installed successfully to /usr/local/bin/mailpit"

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

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

View File

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

File diff suppressed because it is too large Load Diff

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

View 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
internal/htmlcheck/css.go Normal file
View 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/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/vanng822/go-premailer/premailer"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// 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
internal/htmlcheck/html.go Normal file
View File

@@ -0,0 +1,102 @@
package htmlcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/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
internal/htmlcheck/main.go Normal file
View 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>")
}

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

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

View File

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

View File

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

View File

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

76
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,76 @@
// Package logger handles the logging
package logger
import (
"encoding/json"
"fmt"
"os"
"regexp"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
// VerboseLogging for verbose logging
VerboseLogging bool
// QuietLogging shows only errors
QuietLogging bool
// NoLogging shows only fatal errors
NoLogging bool
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}
log.Out = os.Stdout
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
ForceColors: true,
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}
// CleanIP returns a human-readable IP for the logging interface
// 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) {
return "0.0.0.0:" + s[5:]
}
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
}

View File

@@ -0,0 +1,764 @@
// Package storage handles all database actions
package storage
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/mail"
"os"
"os/signal"
"path"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
uuid "github.com/satori/go.uuid"
// sqlite (native) - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite"
)
var (
db *sql.DB
dbFile string
dbIsTemp bool
dbLastAction time.Time
dbIsIdle bool
dbDataDeleted bool
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbDecoder, _ = zstd.NewReader(nil)
)
// InitDB will initialise the database
func InitDB() error {
p := config.DataFile
if p == "" {
// when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
dbIsTemp = true
logger.Log().Debugf("[db] using temporary database: %s", p)
} else {
p = filepath.Clean(p)
}
config.DataFile = p
logger.Log().Debugf("[db] opening database %s", p)
var err error
dsn := fmt.Sprintf("file:%s?cache=shared", p)
db, err = sql.Open("sqlite", dsn)
if err != nil {
return err
}
// prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1)
// create tables if necessary & apply migrations
if err := dbApplyMigrations(); err != nil {
return err
}
dbFile = p
dbLastAction = time.Now()
sigs := make(chan os.Signal, 1)
// catch all signals since not explicitly listing
// Program that will listen to the SIGINT and SIGTERM
// SIGINT will listen to CTRL-C.
// SIGTERM will be caught if kill command executed
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
// method invoked upon seeing signal
go func() {
s := <-sigs
fmt.Printf("[db] got %s signal, shutting down\n", s)
Close()
os.Exit(0)
}()
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
// Close will close the database, and delete if a temporary table
func Close() {
if db != nil {
if err := db.Close(); err != nil {
logger.Log().Warning("[db] error closing database, ignoring")
}
}
if dbIsTemp && isFile(dbFile) {
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
if err := os.Remove(dbFile); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
}
// Store will save an email to the database tables.
// Returns the database ID of the saved message.
func Store(body []byte) (string, error) {
// Parse message body with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
if err != nil {
logger.Log().Warningf("[db] %s", err.Error())
return "", nil
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
obj := DBMailSummary{
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 {
created = mDate
}
}
// generate the search text
searchText := createSearchText(env)
// generate unique ID
id := uuid.NewV4().String()
summaryJSON, err := json.Marshal(obj)
if err != nil {
return "", err
}
// 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 {
return "", err
}
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
// roll back if it fails
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := len(body)
inline := len(env.Inlines)
attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML)
// insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read, Snippet) values(?,?,?,?,?,?,?,?,?,?,0, ?)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON), snippet)
if err != nil {
return "", err
}
// insert compressed raw message
compressed := dbEncoder.EncodeAll(body, make([]byte, 0, len(body)))
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
c := &MessageSummary{}
if err := json.Unmarshal(summaryJSON, c); err != nil {
return "", err
}
c.Created = created
c.ID = id
c.MessageID = messageID
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
c.Snippet = snippet
websockets.Broadcast("new", c)
dbLastAction = time.Now()
BroadcastMailboxStats()
return id, nil
}
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Snippet`).
OrderBy("Created DESC").
Limit(limit).
Offset(start)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var snippet string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
results = append(results, em)
}); err != nil {
return results, err
}
dbLastAction = time.Now()
return results, nil
}
// 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) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From("mailbox").
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
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 = time.UnixMilli(created)
}); err != nil {
logger.Log().Error(err)
}
}
obj := Message{
ID: id,
MessageID: messageID,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: len(raw),
Text: env.Text,
}
obj.HTML = env.HTML
obj.Inline = []Attachment{}
obj.Attachments = []Attachment{}
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
}
}
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
}
dbLastAction = time.Now()
return &obj, nil
}
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i string
var msg string
q := sqlf.From("mailbox_data").
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return nil, err
}
if i == "" {
return nil, errors.New("message not found")
}
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
dbLastAction = time.Now()
return raw, err
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
for _, a := range env.Inlines {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.OtherParts {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.Attachments {
if a.PartID == partID {
return a, nil
}
}
dbLastAction = time.Now()
return nil, errors.New("attachment not found")
}
// MarkRead will mark a message as read
func MarkRead(id string) error {
if !IsUnread(id) {
return nil
}
_, err := sqlf.Update("mailbox").
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
BroadcastMailboxStats()
return err
}
// MarkAllRead will mark all messages as read
func MarkAllRead() error {
var (
start = time.Now()
total = CountUnread()
)
_, err := sqlf.Update("mailbox").
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update("mailbox").
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
return nil
}
_, err := sqlf.Update("mailbox").
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
BroadcastMailboxStats()
return err
}
// DeleteOneMessage will delete a single message from a mailbox
func DeleteOneMessage(id string) error {
// 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()
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
if err != nil {
return err
}
err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted message %s", id)
}
dbLastAction = time.Now()
dbDataDeleted = true
BroadcastMailboxStats()
return err
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages() error {
var (
start = time.Now()
total int
)
_ = sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
// begin a transaction to ensure both the message
// summaries 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()
_, err = tx.Exec("DELETE FROM mailbox")
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM mailbox_data")
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
_, err = db.Exec("VACUUM")
if err == nil {
elapsed := time.Since(start)
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
}
dbLastAction = time.Now()
dbDataDeleted = false
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
return err
}
// GetAllTags returns all used tags
func GetAllTags() []string {
q := sqlf.From("mailbox").
Select(`DISTINCT Tags`).
Where("Tags != ?", "[]")
var tags = []string{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var tagData string
t := []string{}
if err := row.Scan(&tagData); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tagData), &t); err != nil {
logger.Log().Error(err)
return
}
for _, tag := range t {
if !inArray(tag, tags) {
tags = append(tags, tag)
}
}
}); err != nil {
logger.Log().Error(err)
}
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,
Tags: tags,
}
}
// CountTotal returns the number of emails in the database
func CountTotal() int {
var total int
_ = sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
return total
}
// CountUnread returns the number of emails in the database that are unread.
func CountUnread() int {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("Read = ?", 0)
_ = q.QueryRowAndClose(nil, db)
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() int {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("Read = ?", 1)
_ = q.QueryRowAndClose(nil, db)
return total
}
// IsUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func IsUnread(id string) bool {
var unread int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id)
_ = q.QueryRowAndClose(nil, db)
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
}

View File

@@ -0,0 +1,175 @@
package storage
import (
"testing"
"time"
)
func TestTextEmailInserts(t *testing.T) {
setup()
defer Close()
t.Log("Testing text email storage")
start := time.Now()
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
assertEqualStats(t, 0, 0)
}
func TestMimeEmailInserts(t *testing.T) {
setup()
defer Close()
t.Log("Testing mime email storage")
start := time.Now()
for i := 0; i < testRuns; i++ {
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
}
func TestRetrieveMimeEmail(t *testing.T) {
setup()
defer Close()
t.Log("Testing mime email retrieval")
id, err := Store(testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
}
func TestMessageSummary(t *testing.T) {
setup()
defer Close()
t.Log("Testing message summary")
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
summaries, err := List(0, 1)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "Expected 1 result")
msg := summaries[0]
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
}
func BenchmarkImportText(b *testing.B) {
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(testTextEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
}
func BenchmarkImportMime(b *testing.B) {
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(testMimeEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
}

View File

@@ -0,0 +1,200 @@
package storage
import (
"bytes"
"context"
"database/sql"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/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")
}

View File

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

View File

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

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

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

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

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

View File

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

124
internal/storage/structs.go Normal file
View File

@@ -0,0 +1,124 @@
package storage
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message data excluding physical attachments
//
// swagger:model Message
type Message struct {
// Database ID
ID string
// Message ID
MessageID string
// From address
From *mail.Address
// To addresses
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// ReplyTo addresses
ReplyTo []*mail.Address
// Return-Path
ReturnPath string
// Message subject
Subject string
// Message date if set, else date received
Date time.Time
// Message tags
Tags []string
// Message body text
Text string
// Message body HTML
HTML string
// Message size in bytes
Size int
// Inline message attachments
Inline []Attachment
// Message attachments
Attachments []Attachment
}
// Attachment struct for inline and attachments
//
// swagger:model Attachment
type Attachment struct {
// Attachment part ID
PartID string
// File name
FileName string
// Content type
ContentType string
// Content ID
ContentID string
// Size in bytes
Size int
}
// MessageSummary struct for frontend messages
//
// swagger:model MessageSummary
type MessageSummary struct {
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
From *mail.Address
// To address
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// Email subject
Subject string
// Created time
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size int
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
Snippet string
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
Tags []string
}
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
return o
}

113
internal/storage/tags.go Normal file
View File

@@ -0,0 +1,113 @@
package storage
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)
// SetTags will set the tags for a given database ID, used via API
func SetTags(id string, tags []string) error {
applyTags := []string{}
for _, t := range tags {
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)
return err
}
_, err = sqlf.Update("mailbox").
Set("Tags", string(tagJSON)).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id)
}
return err
}
// 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 tagStr
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
}
}
return tagStr
}
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
var data string
q := sqlf.From("mailbox").
Select(`Tags`).To(&data).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
logger.Log().Error(err)
return tags
}
if err := json.Unmarshal([]byte(data), &tags); err != nil {
logger.Log().Error(err)
return tags
}
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
}

View File

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

View File

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

View File

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

229
internal/storage/utils.go Normal file
View File

@@ -0,0 +1,229 @@
package storage
import (
"context"
"database/sql"
"net/mail"
"os"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/leporo/sqlf"
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
data, err := env.AddressList(key)
if err != nil || data == nil {
return []*mail.Address{}
}
return data
}
// Generate the search text based on some header fields (to, from, subject etc)
// and either the stripped HTML body (if exists) or text body
func createSearchText(env *enmime.Envelope) string {
var b strings.Builder
b.WriteString(env.GetHeader("From") + " ")
b.WriteString(env.GetHeader("Subject") + " ")
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,
html2text.WithLinksInnerText(),
),
)
if h != "" {
b.WriteString(h + " ")
} else {
b.WriteString(env.Text + " ")
}
// add attachment filenames
for _, a := range env.Attachments {
b.WriteString(a.FileName + " ")
}
d := cleanString(b.String())
return d
}
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
str = strings.ReplaceAll(str, string('\uFEFF'), " ")
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
}
// Auto-prune runs every minute to automatically delete oldest messages
// if total is greater than the threshold
func dbCron() {
for {
time.Sleep(60 * time.Second)
start := time.Now()
// 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)
if dbDataDeleted && diff.Minutes() > 5 {
dbDataDeleted = false
_, err := db.Exec("VACUUM")
if err == nil {
elapsed := time.Since(start)
logger.Log().Debugf("[db] compressed idle database in %s", elapsed)
}
continue
}
if config.MaxMessages > 0 {
q := sqlf.Select("ID").
From("mailbox").
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
if err := q.Query(nil, db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
if len(ids) == 0 {
continue
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
err = tx.Commit()
if err != nil {
logger.Log().Errorf(err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf(err.Error())
}
}
dbDataDeleted = true
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
websockets.Broadcast("prune", nil)
}
}
}
// IsFile returns whether a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
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 {
if strings.ToLower(v) == k {
return true
}
}
return false
}
// escPercentChar replaces `%` with `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}
// Escape certain characters in search phrases
func escSearch(str string) string {
dest := make([]byte, 0, 2*len(str))
var escape byte
for i := 0; i < len(str); i++ {
c := str[i]
escape = 0
switch c {
case 0: /* Must be escaped for 'mysql' */
escape = '0'
break
case '\n': /* Must be escaped for logs */
escape = 'n'
break
case '\r':
escape = 'r'
break
case '\\':
escape = '\\'
break
case '\'':
escape = '\''
break
case '\032': //十进制26,八进制32,十六进制1a, /* This gives problems on Win32 */
escape = 'Z'
}
if escape != 0 {
dest = append(dest, '\\', escape)
} else {
dest = append(dest, c)
}
}
return string(dest)
}

View File

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

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

@@ -0,0 +1,30 @@
package tools
import (
"fmt"
"strings"
"github.com/microcosm-cc/bluemonday"
"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)
}
// StripHTML returns text from an HTML string
func stripHTML(h string) string {
p := bluemonday.StrictPolicy()
// // ensure joining html elements are spaced apart, eg table cells etc
h = strings.ReplaceAll(h, "><", "> <")
// return p.Sanitize(h)
return html.UnescapeString(p.Sanitize(h))
}

59
internal/tools/message.go Normal file
View File

@@ -0,0 +1,59 @@
// Package tools provides various methods for various things
package tools
import (
"bufio"
"bytes"
"net/mail"
"regexp"
"github.com/axllent/mailpit/internal/logger"
)
// RemoveMessageHeaders scans a message for headers, if found them removes them.
// It will only remove a single instance of any header, and is intended to remove
// Bcc & Message-Id.
func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
reBlank := regexp.MustCompile(`^\s+`)
for _, hdr := range headers {
// case-insensitive
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
// header := []byte(hdr + ":")
if m.Header.Get(hdr) != "" {
scanner := bufio.NewScanner(bytes.NewReader(msg))
found := false
hdr := []byte("")
for scanner.Scan() {
line := scanner.Bytes()
if !found && reHdr.Match(line) {
// add the first line starting with <header>:
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
found = true
} else if found && reBlank.Match(line) {
// add any following lines starting with a whitespace (tab or space)
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
} else if found {
// stop scanning, we have the full <header>
break
}
}
if len(hdr) > 0 {
logger.Log().Debugf("[release] removing %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(""), 1)
}
}
}
return msg, nil
}

View File

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

25
internal/tools/tags.go Normal file
View 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
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"syscall"
)
@@ -184,6 +185,10 @@ func extract(filePath string, directory string) error {
}
fileInfo := header.FileInfo()
// paths could contain a '..', is used in a file system operations
if strings.Contains(fileInfo.Name(), "..") {
continue
}
dir := filepath.Join(directory, filepath.Dir(header.Name))
filename := filepath.Join(dir, fileInfo.Name())

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"runtime"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
)
@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
// get the running binary
oldExec, err := os.Executable()
if err != nil {
panic(err)
return "", err
}
if err = replaceFile(oldExec, newExec); err != nil {

View File

@@ -1,43 +0,0 @@
package logger
import (
"encoding/json"
"fmt"
"os"
"github.com/axllent/mailpit/config"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
log.SetLevel(logrus.DebugLevel)
}
if config.NoLogging {
log.SetLevel(logrus.PanicLevel)
}
log.Out = os.Stdout
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
ForceColors: true,
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}

12
main.go
View File

@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/cmd"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
@@ -15,10 +16,19 @@ func main() {
}
// running directly
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
cmd.Execute()
} else {
// symlinked
sendmail.Run()
}
}
// Normalize returns a lowercase string stripped of the file extension (if exists).
// Used for detecting Windows commands which ignores letter casing and `.exe`.
// eg: "MaIlpIT.Exe" returns "mailpit"
func normalize(s string) string {
s = strings.ToLower(s)
return strings.TrimSuffix(s, filepath.Ext(s))
}

3366
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,23 +3,33 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "node esbuild.config.js",
"watch": "WATCH=true node esbuild.config.js",
"package": "MINIFY=true node esbuild.config.js"
"build": "node esbuild.config.mjs",
"watch": "WATCH=true node esbuild.config.mjs",
"package": "MINIFY=true node esbuild.config.mjs",
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
},
"dependencies": {
"axios": "^0.27.2",
"axios": "^1.2.1",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"modern-screenshot": "^4.4.30",
"moment": "^2.29.4",
"remove": "^0.1.5",
"vue": "^3.2.13"
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"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.14.50",
"esbuild-plugin-sass": "^1.0.1",
"esbuild-plugin-vue-next": "^0.1.4"
"esbuild": "^0.19.1",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,24 +1,47 @@
// Package cmd is the sendmail cli
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"
"log"
"io"
"net/mail"
"net/smtp"
"os"
"os/user"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/reiver/go-telnet"
flag "github.com/spf13/pflag"
)
// Run the Mailpit sendmail replacement.
func Run() {
var (
// SMTPAddr address
SMTPAddr = "localhost:1025"
// FromAddr email address
FromAddr string
// UseB - used to set from `-bs`
UseB bool
// UseS - used to set from `-bs`
UseS bool
)
func init() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
@@ -30,36 +53,76 @@ func Run() {
username = user.Username
}
fromAddr := username + "@" + host
smtpAddr := "localhost:1025"
var recip []string
if FromAddr == "" {
FromAddr = username + "@" + host
}
}
// 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")
}
var verbose bool
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.Println(HelpTemplate(os.Args[0:1]))
}
var showHelp bool
// avoid 'pflag: help requested' error
flag.BoolVarP(&showHelp, "help", "h", false, "")
// override defaults from cli flags
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
flag.BoolP("long-i", "i", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
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.Stderr, 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)
@@ -67,19 +130,55 @@ 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)
}
if len(recip) == 0 {
// We only need to parse the message to get a recipient if none where
// provided on the command line.
recip = append(recip, msg.Header.Get("To"))
addresses := []string{}
if len(recipients) > 0 {
addresses = recipients
} else {
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
addresses = append(addresses, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
addresses = append(addresses, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
addresses = append(addresses, a.Address)
}
}
}
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
err = smtp.SendMail(SMTPAddr, nil, FromAddr, addresses, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
log.Fatal(err)
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)
}

View File

@@ -1,224 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
type messagesResult struct {
Total int `json:"total"`
Unread int `json:"unread"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}
// Return a list of available mailboxes
func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
res, err := storage.ListMailboxes()
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
start, limit := getStartLimit(r)
messages, err := storage.List(mailbox, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
// we will only return up to 200 results
start := 0
limit := 200
messages, err := storage.Search(mailbox, search, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
msg, err := storage.GetMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
// Download/view an attachment
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(mailbox, id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
w.Write(a.Content)
}
// View the full email source as plain text
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
w.Write(data)
}
// Delete all messages in the mailbox
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
err := storage.DeleteAllMessages(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Delete a single message
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.DeleteOneMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Mark single message as unread
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.UnreadMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

852
server/apiv1/api.go Normal file
View File

@@ -0,0 +1,852 @@
// Package apiv1 handles all the API responses
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
uuid "github.com/satori/go.uuid"
)
// GetMessages returns a paginated list of messages as JSON
func GetMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/messages messages GetMessages
//
// # List messages
//
// Returns messages from the mailbox ordered from newest to oldest.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: start
// in: query
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: Limit results
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = 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")
_, _ = w.Write(bytes)
}
// Search returns the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/search messages MessagesSummary
//
// # Search messages
//
// Returns the latest messages matching a search.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: 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
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
start, limit := getStartLimit(r)
messages, results, err := storage.Search(search, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
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
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = 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
//
// # Get message summary
//
// Returns the summary of a message, marking the message as read.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: Message
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// DownloadAttachment (method: GET) returns the attachment data
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
//
// # Get message attachment
//
// This will return the attachment part using the appropriate Content-Type.
//
// Produces:
// - application/*
// - image/*
// - text/*
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
fourOFour(w)
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// GetHeaders (method: GET) returns the message headers as JSON
func GetHeaders(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/headers message Headers
//
// # Get message headers
//
// Returns the message headers as an array.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: MessageHeaders
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
reader := bytes.NewReader(data)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(m.Header)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/raw message Raw
//
// # Get message source
//
// Returns the full email source as plain text.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages Delete
//
// # Delete messages
//
// If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to delete
// required: false
// type: DeleteRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil || len(data.IDs) == 0 {
if err := storage.DeleteAllMessages(); err != nil {
httpError(w, err.Error())
return
}
} else {
for _, id := range data.IDs {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write([]byte("ok"))
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatus
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: false
// type: SetReadStatusRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
} else {
err := storage.MarkAllUnread()
if err != nil {
httpError(w, err.Error())
return
}
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = 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
//
// # Set message tags
//
// To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: true
// type: SetTagsRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if err := storage.SetTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// 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 pre-configured external SMTP server..
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: to
// in: body
// description: Array of email addresses to release message to
// required: true
// type: ReleaseMessageRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequest{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
tos := data.To
if len(tos) == 0 {
httpError(w, "No valid addresses found")
return
}
for _, to := range tos {
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)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
froms, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
from := froms[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc", "Message-Id"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP mfrom
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// generate unique ID
uid := uuid.NewV4().String() + "@mailpit"
// add unique ID
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
if err := smtpd.Send(from, tos, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = 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")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
}
// 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)
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
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(""))
}

74
server/apiv1/info.go Normal file
View File

@@ -0,0 +1,74 @@
package apiv1
import (
"encoding/json"
"net/http"
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/updater"
)
// Response includes the current and latest Mailpit version, database info, and memory usage
//
// swagger:model AppInformation
type appInformation struct {
// Current Mailpit version
Version string
// Latest Mailpit version
LatestVersion string
// Database path
Database string
// Database size in bytes
DatabaseSize int64
// Total number of messages in the database
Messages int
// Current memory usage in bytes
Memory uint64
}
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: InfoResponse
// default: ErrorResponse
info := appInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.Memory = m.Sys - m.HeapReleased
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
}
info.Database = config.DataFile
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Messages = storage.CountTotal()
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

55
server/apiv1/structs.go Normal file
View File

@@ -0,0 +1,55 @@
package apiv1
import (
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/storage"
)
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total int `json:"total"`
// Total number of unread messages in mailbox
Unread int `json:"unread"`
// 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"`
// All current tags
Tags []string `json:"tags"`
// Messages summary
// in:body
Messages []storage.MessageSummary `json:"messages"`
}
// The following structs & aliases are provided for easy import
// and understanding of the JSON structure.
// MessageSummary - summary of a single message
type MessageSummary = storage.MessageSummary
// Message data
type Message = storage.Message
// Attachment summary
type Attachment = storage.Attachment
// HTMLCheckResponse summary
type HTMLCheckResponse = htmlcheck.Response
// LinkCheckResponse summary
type LinkCheckResponse = linkcheck.Response

View File

@@ -0,0 +1,19 @@
consumes:
- application/json
info:
description: |-
OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).
title: Mailpit API
contact:
name: GitHub
url: https://github.com/axllent/mailpit
license:
name: MIT license
url: https://github.com/axllent/mailpit/blob/develop/LICENSE
version: "v1"
paths: {}
produces:
- application/json
schemes:
- http
swagger: "2.0"

109
server/apiv1/swagger.go Normal file
View File

@@ -0,0 +1,109 @@
package apiv1
// These structs are for the purpose of defining swagger HTTP responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
Body appInformation
}
// Web UI configuration
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
Body webUIConfiguration
}
// Message summary
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
// The message summary
// in: body
Body MessagesSummary
}
// Message headers
// swagger:model MessageHeaders
type messageHeaders map[string][]string
// Delete request
// swagger:model DeleteRequest
type deleteRequest struct {
// ids
// in:body
IDs []string `json:"ids"`
}
// Set read status request
// swagger:model SetReadStatusRequest
type setReadStatusRequest struct {
// Read status
Read bool `json:"read"`
// ids
// in:body
IDs []string `json:"ids"`
}
// Set tags request
// swagger:model SetTagsRequest
type setTagsRequest struct {
// Tags
// in:body
Tags []string `json:"tags"`
// IDs
// in:body
IDs []string `json:"ids"`
}
// Release request
// swagger:model ReleaseMessageRequest
type releaseMessageRequest struct {
// To
// in:body
To []string `json:"to"`
}
// Binary data response inherits the attachment's content type
// swagger:response BinaryResponse
type binaryResponse struct {
// in: body
Body string
}
// Plain text response
// swagger:response TextResponse
type textResponse struct {
// in: body
Body string
}
// HTML response
// swagger:response HTMLResponse
type htmlResponse struct {
// in: body
Body string
}
// Error response
// swagger:response ErrorResponse
type errorResponse struct {
// The error message
// in: body
Body string
}
// Plain text "ok" response
// swagger:response OKResponse
type okResponse struct {
// Default response
// in: body
Body string
}
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string

134
server/apiv1/thumbnails.go Normal file
View File

@@ -0,0 +1,134 @@
package apiv1
import (
"bufio"
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net/http"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
var (
thumbWidth = 180
thumbHeight = 120
)
// Thumbnail returns a thumbnail image for an attachment (images only)
func Thumbnail(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
//
// # Get an attachment image thumbnail
//
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
//
// Produces:
// - image/jpeg
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
if !strings.HasPrefix(a.ContentType, "image/") {
blankImage(a, w)
return
}
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf)
if err != nil {
// it's not an image, return default
logger.Log().Warning(err)
blankImage(a, w)
return
}
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)
// paste the original over the top
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
blankImage(a, w)
return
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}
// Return a blank image instead of an error when file or image not supported
func blankImage(a *enmime.Part, w http.ResponseWriter) {
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
img := image.NewRGBA(rect)
background := color.RGBA{255, 255, 255, 255}
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
var b bytes.Buffer
foo := bufio.NewWriter(&b)
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}

63
server/apiv1/webui.go Normal file
View File

@@ -0,0 +1,63 @@
package apiv1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/axllent/mailpit/config"
)
// Response includes global web UI settings
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
Enabled bool
// The configured SMTP server address
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, _ *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
//
// Schemes: http, https
//
// Responses:
// 200: WebUIConfigurationResponse
// default: ErrorResponse
conf := webUIConfiguration{}
conf.MessageRelay.Enabled = config.ReleaseEnabled
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")
_, _ = w.Write(bytes)
}

View File

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

View File

@@ -0,0 +1,17 @@
package handlers
import (
"net/http"
"sync/atomic"
)
// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic
func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if isReady == nil || !isReady.Load().(bool) {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}

View File

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

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

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

View File

@@ -1,19 +1,25 @@
// Package server is the HTTP daemon
package server
import (
"bytes"
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"text/template"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
@@ -21,8 +27,14 @@ import (
//go:embed ui
var embeddedFS embed.FS
// AccessControlAllowOrigin CORS policy
var AccessControlAllowOrigin string
// Listen will start the httpd
func Listen() {
isReady := &atomic.Value{}
isReady.Store(false)
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err)
@@ -33,34 +45,96 @@ func Listen() {
go websockets.MessageHub.Run()
r := mux.NewRouter()
r.HandleFunc("/api/mailboxes", middleWareFunc(apiListMailboxes))
r.HandleFunc("/api/{mailbox}/messages", middleWareFunc(apiListMailbox))
r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox))
r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll))
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource))
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne))
r.HandleFunc("/api/{mailbox}/{id}/unread", middleWareFunc(apiUnreadOne))
r.HandleFunc("/api/{mailbox}/{id}", middleWareFunc(apiOpenMessage))
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
r := apiRoutes()
// kubernetes probes
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redirect := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
// frontend testing
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
// web UI via virtual index.html
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
// put it all together
http.Handle("/", r)
if config.SSLCert != "" && config.SSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.SSLCert, config.SSLKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
if auth.UICredentials != nil {
logger.Log().Info("[http] enabling basic authentication")
}
// Mark the application here as ready
isReady.Store(true)
if config.UITLSCert != "" && config.UITLSKey != "" {
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.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
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
}
// BasicAuthResponse returns an basic auth response to the browser
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorised.\n"))
_, _ = w.Write([]byte("Unauthorised.\n"))
}
type gzipResponseWriter struct {
@@ -76,7 +150,16 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
// and gzip compression.
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if config.AuthFile != "" {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -84,7 +167,21 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return
}
if !config.Auth.Match(user, pass) {
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
@@ -106,8 +203,16 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if config.AuthFile != "" {
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
@@ -115,7 +220,7 @@ func middlewareHandler(h http.Handler) http.Handler {
return
}
if !config.Auth.Match(user, pass) {
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
@@ -132,37 +237,83 @@ func middlewareHandler(h http.Handler) http.Handler {
})
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
// Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
storage.BroadcastMailboxStats()
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
start = int(n)
// 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)
}
l := req.URL.Query().Get("limit")
if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 {
if n > 500 {
n = 500
}
limit = int(n)
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)
}
return start, limit
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(f)
}
// Just returns the default HTML template
func index(w http.ResponseWriter, _ *http.Request) {
var h = `<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="{{ .Webroot }}favicon.svg">
<title>Mailpit</title>
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
</head>
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
<noscript>You require JavaScript to use this app.</noscript>
</div>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
</body>
</html>`
t, err := template.New("index").Parse(h)
if err != nil {
panic(err)
}
data := struct {
Webroot string
Version string
}{
Webroot: config.Webroot,
Version: config.Version,
}
buff := new(bytes.Buffer)
err = t.Execute(buff, data)
if err != nil {
panic(err)
}
buff.Bytes()
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(buff.Bytes())
}

370
server/server_test.go Normal file
View File

@@ -0,0 +1,370 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/apiv1"
"github.com/jhillyerd/enmime"
)
var (
putDataStruct struct {
Read bool `json:"read"`
IDs []string `json:"ids"`
}
)
func TestAPIv1Messages(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// check count of empty database
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// store this for later tests
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// read first 10 messages
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for idx, msg := range m.Messages {
if idx == 10 {
break
}
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
t.Errorf(err.Error())
}
// get RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
}
// het headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error())
}
// store for later
putIDS = append(putIDS, msg.ID)
}
// 10 should be marked as read
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// delete all
t.Log("Delete all messages")
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
}
func TestAPIv1ToggleReadStatus(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// check count of empty database
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// read first 10 IDs
t.Log("Get first 10 IDs")
putIDS := []string{}
for idx, msg := range m.Messages {
if idx == 10 {
break
}
// store for later
putIDS = append(putIDS, msg.ID)
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark first 10 as unread
t.Log("Mark first 10 as read")
putData := putDataStruct
putData.Read = true
putData.IDs = putIDS
j, err := json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// mark first 10 as read
t.Log("Mark first 10 as unread")
putData.Read = false
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark all as read
putData.Read = true
putData.IDs = []string{}
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
t.Log("Mark all read")
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
}
func TestAPIv1Search(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// search
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-from:from-1@example.com", 99)
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := storage.InitDB(); err != nil {
panic(err)
}
}
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
m := apiv1.MessagesSummary{}
data, err := clientGet(uri)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, unread, m.Unread, "wrong unread count")
assertEqual(t, total, m.Total, "wrong total count")
}
func assertSearchEqual(t *testing.T, uri, query string, count int) {
t.Logf("Test search: %s", query)
m := apiv1.MessagesSummary{}
limit := fmt.Sprintf("%d", count)
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, m.MessagesCount, "wrong search results count")
}
func insertEmailData(t *testing.T) {
for i := 0; i < 100; 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 := storage.Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
m := apiv1.MessagesSummary{}
data, err := clientGet(url)
if err != nil {
return m, err
}
if err := json.Unmarshal(data, &m); err != nil {
return m, err
}
return m, nil
}
func clientGet(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
return data, err
}
func clientDelete(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("DELETE", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
return data, err
}
func clientPut(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("PUT", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
return data, err
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

147
server/smtpd/smtp.go Normal file
View File

@@ -0,0 +1,147 @@
package smtpd
import (
"crypto/tls"
"errors"
"fmt"
"net/mail"
"net/smtp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/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 fmt.Errorf("error connecting to %s: %s", addr, err.Error())
}
defer c.Close()
if config.SMTPRelayConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host}
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
auth := relayAuthFromConfig()
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("error response to DATA command: %s", err.Error())
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
}
return c.Quit()
}
// Return the SMTP relay authentication based on config
func relayAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
return a
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
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
}

237
server/smtpd/smtpd.go Normal file
View File

@@ -0,0 +1,237 @@
// Package smtpd is the SMTP daemon
package smtpd
import (
"bytes"
"fmt"
"net"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/mhale/smtpd"
uuid "github.com/satori/go.uuid"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if !config.SMTPStrictRFCHeaders {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
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 messageID == "" {
// generate unique ID
messageID = uuid.NewV4().String() + "@mailpit"
// add unique ID
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().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)
}
}
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
missingAddresses := []string{}
for _, a := range to {
// loop through passed email addresses to check if they are in the headers
if _, err := mail.ParseAddress(a); err == nil {
_, ok := emails[strings.ToLower(a)]
if !ok {
missingAddresses = append(missingAddresses, a)
}
} else {
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
}
}
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
if hasBccHeader {
// email already has Bcc header, add to existing addresses
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurrence
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
})
} else {
// prepend new Bcc header
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
data = append(bcc, data...)
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
_, err = storage.Store(data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
allow := auth.SMTPCredentials.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
}
return allow, nil
}
// Allow any username and password
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
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login auth (insecure)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling all auth (insecure)")
}
} else {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login auth (TLS)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any auth (TLS)")
}
}
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}
if auth.SMTPCredentials != nil {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandlerAny
}
if config.SMTPTLSCert != "" {
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
return err
}
}
return srv.ListenAndServe()
}
func cleanIP(i net.Addr) string {
parts := strings.Split(i.String(), ":")
return parts[0]
}
// Returns a list of all lowercased emails found in To, Cc and Bcc,
// as well as whether there is a Bcc field
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
emails := make(map[string]bool)
hasBccHeader := false
if recipients, err := h.AddressList("To"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
if recipients, err := h.AddressList("Cc"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
recipients, err := h.AddressList("Bcc")
if err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
hasBccHeader = true
}
return emails, hasBccHeader
}

View File

@@ -1,421 +1,37 @@
<script>
import commonMixins from './mixins.js'
import Message from './templates/Message.vue';
import moment from 'moment'
import CommonMixins from './mixins/CommonMixins'
import Notifications from './components/Notifications.vue'
import { RouterView } from 'vue-router'
import { mailbox } from "./stores/mailbox"
export default {
mixins: [commonMixins],
mixins: [CommonMixins],
components: {
Message
Notifications,
},
data() {
return {
currentPath: window.location.hash,
mailbox: "catchall",
items: [],
limit: 50,
total: 0,
unread: 0,
start: 0,
search: "",
searching: false,
isConnected: false,
scrollInPlace: false,
message: false
}
beforeMount() {
document.title = document.title + ' - ' + location.hostname
mailbox.showTagColors = localStorage.getItem('showTagsColors') == '1'
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
})
},
watch: {
currentPath(v, old) {
if (v && v.match(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/)) {
this.openMessage();
} else {
this.message = false;
}
$route(to, from) {
// hide mobile menu on URL change
this.hideNav()
}
},
computed: {
canPrev: function () {
return this.start > 0;
},
canNext: function () {
return this.total > (this.start + this.count);
}
},
mounted() {
this.currentPath = window.location.hash.slice(1);
window.addEventListener('hashchange', () => {
this.currentPath = window.location.hash.slice(1);
});
this.connect();
this.loadMessages();
},
methods: {
loadMessages: function () {
let self = this;
let params = {};
let uri = 'api/'+self.mailbox+'/messages';
if (self.search) {
self.searching = true;
self.items = [];
uri = 'api/'+self.mailbox+'/search'
self.start = 0; // search is displayed on one page
params['query'] = self.search;
} else {
self.searching = false;
params['limit'] = self.limit;
if (self.start > 0) {
params['start'] = self.start;
}
}
self.get(uri, params, function(response){
self.total = response.data.total;
self.unread = response.data.unread;
self.count = response.data.count;
self.start = response.data.start;
self.items = response.data.items;
if (!self.scrollInPlace) {
let mp = document.getElementById('message-page');
if (mp) {
mp.scrollTop = 0;
}
}
self.scrollInPlace = false
});
},
doSearch: function(e) {
e.preventDefault();
this.loadMessages();
},
reloadMessages: function() {
this.search = "";
this.start = 0;
this.loadMessages();
},
viewNext: function () {
this.start = parseInt(this.start, 10) + parseInt(this.limit, 10);
this.loadMessages();
},
viewPrev: function () {
let s = this.start - this.limit;
if (s < 0) {
s = 0;
}
this.start = s;
this.loadMessages();
},
openMessage: function(id) {
let self = this;
let params = {};
let uri = 'api/' + self.mailbox + '/' + self.currentPath
self.get(uri, params, function(response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
if (!self.items[i].Read) {
self.items[i].Read = true;
self.unread--;
}
}
}
let d = response.data;
// replace inline images
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, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
);
}
}
}
// replace inline images
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, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
);
}
}
}
self.message = d;
});
},
deleteAll: function() {
let self = this;
let uri = 'api/' + self.mailbox + '/delete'
self.get(uri, false, function(response) {
self.reloadMessages();
});
},
deleteOne: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/delete'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markUnread: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/unread'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// websocket connect
connect: function () {
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
let ws = new WebSocket(wsproto + "://" + document.location.host + "/api/"+this.mailbox+"/events");
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 (self.start < 1) {
if (!self.searching) {
self.items.unshift(response.Data);
if (self.items.length > self.limit) {
self.items.pop();
}
}
}
self.total++;
self.unread++;
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
self.loadMessages();
}
}
ws.onopen = function () {
self.isConnected = true;
self.loadMessages();
}
ws.onclose = function (e) {
self.isConnected = false;
setTimeout(function () {
self.connect(); // reconnect
}, 1000);
}
ws.onerror = function (err) {
ws.close();
}
},
getPrimaryEmailTo: function(message) {
for (let i in message.To) {
return message.To[i].Address;
}
return '[ Unknown ]';
},
getRelativeCreated: function(message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString();
},
}
}
</script>
<template>
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light">
<div class="col-lg-2 col-md-3 col-auto">
<a class="navbar-brand" href="#" v-on:click="reloadMessages">
<img src="mailpit.svg" alt="Mailpit">
<span class="d-none d-md-inline-block ms-2">Mailpit</span>
</a>
</div>
<div class="col col-md-9 col-lg-8" v-if="message">
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
<i class="bi bi-arrow-return-left"></i>
</a>
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
<i class="bi bi-trash-fill"></i>
</button>
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-envelope"></i>
</button>
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2" title="Download message">
<i class="bi bi-file-arrow-down-fill"></i>
</a>
</div>
<div class="col col-md-9 col-lg-5" v-if="!message && total">
<form v-on:submit="doSearch">
<div class="input-group">
<input type="text" class="form-control" v-model.trim="search" placeholder="Search mailbox">
<button class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
</div>
</form>
</div>
<div class="col-12 col-lg-5 text-end" v-if="!message && total">
<select v-model="limit" v-on:change="loadMessages"
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<span v-if="searching">
<b>{{ formatNumber(items.length) }} results</b>
</span>
<span v-else>
<small>
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
</small>
<button class="btn btn-outline-secondary ms-3 me-1" :disabled="!canPrev" v-on:click="viewPrev"
v-if="!searching">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
</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;">
<ul class="list-unstyled mt-3">
<li v-if="isConnected" title="Messages will auto-load">
<i class="bi bi-power text-success"></i>
Connected
</li>
<li v-else title="Messages will auto-load">
<i class="bi bi-power text-danger"></i>
Disconnected
</li>
<li class="mt-3">
<a class="position-relative ps-0" href="#" v-on:click="reloadMessages">
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
Inbox
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
{{ formatNumber(unread) }}
</span>
</a>
</li>
<li class="mt-3 mb-5">
<a v-if="total" href="#" data-bs-toggle="modal" data-bs-target="#deleteAllModal">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</a>
</li>
<li class="mt-5 position-fixed bottom-0 w-100">
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white py-2">
<i class="bi bi-github"></i>
GitHub
</a>
</li>
</ul>
</div>
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<div class="list-group" v-if="items.length">
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)">
<div class="col-md-3">
<div class="d-md-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-md-none">
<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-md-block">
<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-md-block text-truncate text-muted small">
{{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{message.To.length - 1}}]
</span>
</div>
</div>
<div class="col-md-6 mt-2 mt-md-0">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
</div>
<div class="d-none d-md-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-md-block col-2 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
</a>
</div>
<div v-else class="text-muted py-3">No messages</div>
</div>
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
</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>
</div>
<!-- Modal -->
<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(total) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
</div>
</div>
</div>
<RouterView />
<Notifications />
</template>

View File

@@ -1,8 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/bootstrap.scss";
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')

View 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

View File

@@ -1 +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;

View File

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

View File

@@ -1,54 +1,493 @@
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
@import "bootstrap";
@import "./bootstrap";
[v-cloak] {
display: none !important;
display: none !important;
}
.navbar {
z-index: 99;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
}
.navbar-brand {
color: #2d4a5d;
span {
opacity: 0.8;
transition: all 0.5s;
}
img {
width: 40px;
}
&:hover {
span {
opacity: 1;
}
}
}
#loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
.nav-tabs .nav-link {
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
}
.message.read:not(.active) {
// background: $gray-100;
color: $gray-500;
: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;
}
}
}
#nav-plain-text,
.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 {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
opacity: 0.7;
font-weight: normal;
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
white-space: pre;
font-family: "Courier New", Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-plain-text {
white-space: pre-wrap;
#nav-html-source pre[class*="language-"] code {
white-space: pre-wrap;
}
#nav-plain-text .text-view {
white-space: pre-wrap;
}
.messageHeaders {
margin: 15px 0 0;
margin: 15px 0 0;
th {
padding-right: 10px;
font-weight: normal;
vertical-align: top;
}
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
td {
vertical-align: top;
}
td {
vertical-align: top;
}
}
#nav-html {
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
}
#preview-html {
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
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;
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;
}
&::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%);
}
&::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;
}
body.blur {
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
.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-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
.form-select.tag-selector {
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
}
// bootstrap5-tags
.tags-badge {
display: flex;
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
}
#ReleaseModal {
.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;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
}
code[class*="language-"] {
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;
}
: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;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.function-name,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.function,
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
}
.token.bold {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
<script>
import NavSelected from '../components/NavSelected.vue'
import AjaxLoader from "./AjaxLoader.vue"
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
components: {
NavSelected,
AjaxLoader,
},
props: {
modals: {
type: Boolean,
default: false,
}
},
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
pagination.start = 0
self.loadMessages()
})
}
}
}
</script>
<template>
<template v-if="!modals">
<div class="list-group my-2">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
<span class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread">
{{ formatNumber(mailbox.unread) }}
</span>
</button>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.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>

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