Compare commits

...

164 Commits

Author SHA1 Message Date
Ralph Slooten
1f7a60452e Merge branch 'release/v1.19.2' 2024-07-21 16:11:49 +12:00
Ralph Slooten
14943324e8 Release v1.19.2 2024-07-21 16:11:48 +12:00
Ralph Slooten
b05c6fbf60 Chore: Update Go dependencies 2024-07-21 16:06:26 +12:00
Ralph Slooten
21a6f798d1 Fix: Update Inbox "Delete All" count when new messages are detected (#334) 2024-07-16 16:21:49 +12:00
Ralph Slooten
9014376e80 Merge tag 'v1.19.1' into develop
Release v1.19.1
2024-07-14 15:13:38 +12:00
Ralph Slooten
609b2a64ea Merge branch 'release/v1.19.1' 2024-07-14 15:13:34 +12:00
Ralph Slooten
eb120a231b Release v1.19.1 2024-07-14 15:13:33 +12:00
Ralph Slooten
fd03926260 Chore: Update Go dependencies 2024-07-14 15:08:28 +12:00
Ralph Slooten
6947c2a621 Feature: Add optional relay recipient blocklist (#333) 2024-07-14 15:04:36 +12:00
Ralph Slooten
406fe56fc6 Chore: Equal column widths in About modal 2024-07-07 22:17:21 +12:00
Ralph Slooten
13a418370f Chore: Bump esbuild to version 0.23.0 2024-07-02 19:17:16 +12:00
dependabot[bot]
80a2ab68c2 Chore: Bump esbuild from 0.21.5 to 0.22.0 (#326) 2024-07-01 21:55:50 +12:00
dependabot[bot]
1d9c12b657 Chore: Bump docker/build-push-action from 5 to 6 (#327) 2024-07-01 21:53:29 +12:00
Ralph Slooten
a1b1e97f75 Merge tag 'v1.19.0' into develop
Release v1.19.0
2024-06-29 23:01:55 +12:00
Ralph Slooten
61e8cad507 Merge branch 'release/v1.19.0' 2024-06-29 23:01:51 +12:00
Ralph Slooten
1f0f9efa7a Release v1.19.0 2024-06-29 23:01:49 +12:00
Ralph Slooten
f5f2371839 Merge branch 'feature/tag-edit' into develop 2024-06-29 17:24:07 +12:00
Ralph Slooten
3fcbdb3273 Chore: Update node dependencies 2024-06-29 17:23:07 +12:00
Ralph Slooten
52d8806c01 Chore: Update Go dependencies 2024-06-29 17:17:18 +12:00
Ralph Slooten
b941015632 Consolidate API tag functionality 2024-06-29 17:15:21 +12:00
Ralph Slooten
0c377b9616 Feature: Add ability to rename and delete tags globally 2024-06-29 17:12:56 +12:00
Ralph Slooten
0dca8df29c Feature: Add option to disable auto-tagging for plus-addresses & X-Tags (#323) 2024-06-28 22:35:07 +12:00
Ralph Slooten
c7e0455479 Handle errors correctly 2024-06-22 23:56:17 +12:00
Ralph Slooten
19645db2de Use correct AS case 2024-06-22 23:48:07 +12:00
Ralph Slooten
6373a33bff Merge tag 'v1.18.7' into develop
Release v1.18.7
2024-06-22 23:36:20 +12:00
Ralph Slooten
9a3d0ca337 Merge branch 'release/v1.18.7' 2024-06-22 23:36:12 +12:00
Ralph Slooten
4193489b9e Release v1.18.7 2024-06-22 23:36:09 +12:00
Ralph Slooten
eac0b9d5df Add nosec to audited code 2024-06-22 23:34:18 +12:00
Ralph Slooten
e60fefb33b Update Changelog group title order 2024-06-22 23:07:24 +12:00
Ralph Slooten
0bc8dcc161 Use short hash in edge builds 2024-06-22 13:48:10 +12:00
Ralph Slooten
99c5c1a120 Use short hash in edge builds 2024-06-22 13:46:50 +12:00
Ralph Slooten
33e367d706 Chore: Refactor JavaScript, use arrow functions instead of "self" aliasing 2024-06-22 13:27:00 +12:00
Ralph Slooten
5e5b855a3d UI tweaks 2024-06-22 12:12:18 +12:00
Ralph Slooten
e15a8fecc5 Chore: Handle websocket errors caused by persistent connection failures (#319)
When either websockets do not work, or when they continually break connection (>3 / 15s), websockets will now stop reconnecting.
2024-06-22 12:07:01 +12:00
Ralph Slooten
eb0ef8baff Merge branch 'feature/label' into develop 2024-06-21 16:54:43 +12:00
Ralph Slooten
a155b395db Feature: Add optional label to identify Mailpit instance (#316) 2024-06-21 16:54:33 +12:00
Ralph Slooten
8de2c5ec81 Template formatting 2024-06-21 16:09:48 +12:00
Ralph Slooten
7a55e4d0e2 Enable POP3 integration tests 2024-06-21 15:40:22 +12:00
Ralph Slooten
f7f200c6fe Testing: Add POP3 integration tests 2024-06-21 15:38:30 +12:00
Ralph Slooten
1bd6794b2d Merge tag 'v1.18.6' into develop
Release v1.18.6
2024-06-19 16:25:14 +12:00
Ralph Slooten
7204964cf8 Merge branch 'release/v1.18.6' 2024-06-19 16:25:13 +12:00
Ralph Slooten
a4b081f9b9 Release v1.18.6 2024-06-19 16:25:12 +12:00
Ralph Slooten
1529e424f8 Merge branch 'feature/pop3-fixes' into develop 2024-06-19 16:20:02 +12:00
Ralph Slooten
48045ec0aa Chore: Update caniemail database 2024-06-19 16:18:42 +12:00
Ralph Slooten
545162e6fc Chore: Update node dependencies 2024-06-19 16:17:54 +12:00
Ralph Slooten
d2f586c133 Chore: Update Go dependencies 2024-06-19 16:14:29 +12:00
Ralph Slooten
2cf0b50d1b Rename pop3 server file 2024-06-19 16:10:03 +12:00
Ralph Slooten
70baf12adb Chore: Delete multiple POP3 messages in single action 2024-06-19 16:02:40 +12:00
Ralph Slooten
710f093561 Use consistent POP3 response casing 2024-06-19 15:59:55 +12:00
Ralph Slooten
b7ad94211b Chore: Handle POP3 RSET command 2024-06-19 15:59:18 +12:00
Ralph Slooten
7991c49312 Ensure a user has been set first before a password can be issued 2024-06-19 15:47:05 +12:00
Ralph Slooten
7773c6b04c Commands in the POP3 are case-insensitive (see RFC1939) 2024-06-19 15:46:38 +12:00
Antonio Nardella
a32237e14f Fix: POP3 end of file reached error (#315)
* Changed POP3 size output to show compatible size

* Setting POP3 10 minutes timeout according to RFC1939

* fixed issue with unauthorized commands access, refactor

* readded package description

* fixes error strings should not be capitalized (ST1005)go-staticcheck
2024-06-19 15:34:40 +12:00
Antonio Nardella
ce7dcce61c Fix: POP3 size output to show compatible sizes (#312)
* Changed POP3 size output to show compatible size

* Setting POP3 10 minutes timeout according to RFC1939
2024-06-15 08:50:22 +12:00
Ralph Slooten
83c94c879a Merge tag 'v1.18.5' into develop
Release v1.18.5
2024-06-07 14:20:07 +12:00
Ralph Slooten
029db4bc00 Merge branch 'release/v1.18.5' 2024-06-07 14:20:05 +12:00
Ralph Slooten
b595af6b72 Release v1.18.5 2024-06-07 14:20:05 +12:00
Ralph Slooten
79e1f9d773 Chore: Update node dependencies 2024-06-07 14:13:24 +12:00
Ralph Slooten
28a8502a65 Chore: Update Go dependencies 2024-06-07 14:11:48 +12:00
Ralph Slooten
7105450cc7 Correctly handle browser back/forward navigation with pagination 2024-06-07 14:05:50 +12:00
Ralph Slooten
8a6d71ed9c Merge branch 'feature/query-parameters' into develop 2024-06-06 16:14:24 +12:00
Ralph Slooten
aa3f94457c Improve pagination & limit URL parameter handling 2024-06-02 16:07:26 +12:00
Yuuki Takahashi
e87b98b73b Feature: Add pagination & limits to URL parameters (#303)
* Set search conditions to query parameters

* Fixed by review

* Update query parameters when new message notified
2024-06-02 15:37:38 +12:00
Ralph Slooten
21eef69a60 Merge tag 'v1.18.4' into develop
Release v1.18.4
2024-06-01 22:48:52 +12:00
Ralph Slooten
1fb869fb5e Merge branch 'release/v1.18.4' 2024-06-01 22:48:47 +12:00
Ralph Slooten
31390e4b82 Set booxmedialtd/ws-action-parse-semver action version 2024-06-01 22:46:46 +12:00
Ralph Slooten
3974fdfbaf Merge tag 'v1.18.4' into develop
Release v1.18.4
2024-06-01 22:35:38 +12:00
Ralph Slooten
9909fd969c Merge branch 'release/v1.18.4' 2024-06-01 22:35:36 +12:00
Ralph Slooten
abd1f0b008 Release v1.18.4 2024-06-01 22:35:34 +12:00
Ralph Slooten
0dbbb821eb Chore: Update node dependencies 2024-06-01 22:28:41 +12:00
Ralph Slooten
262be51c9b Minor change to timezone dropdown 2024-06-01 22:27:40 +12:00
Ralph Slooten
5dee4cc763 Chore: Update Go dependencies 2024-06-01 22:25:42 +12:00
Ralph Slooten
f89fa46902 Chore: Clone new Docker images to ghcr.io (#302)
This is for convenience, and the primary Docker registry remains on https://hub.docker.com/r/axllent/mailpit
2024-05-26 19:27:00 +12:00
Ralph Slooten
c25dee57c3 Test ghcr.io packages 2024-05-26 18:38:19 +12:00
Ralph Slooten
e192d5efd2 Add dot-stuffing POP3 comment & RFC link 2024-05-19 00:42:52 +12:00
Ralph Slooten
0de93c7868 Merge tag 'v1.18.3' into develop
Release v1.18.3
2024-05-18 23:56:46 +12:00
Ralph Slooten
3e28acde6a Merge branch 'release/v1.18.3' 2024-05-18 23:56:43 +12:00
Ralph Slooten
ae05840571 Release v1.18.3 2024-05-18 23:56:42 +12:00
Ralph Slooten
4269192f32 Chore: Update Go dependencies 2024-05-18 23:54:31 +12:00
Ralph Slooten
35fb3d1790 Chore: Update node dependencies 2024-05-18 23:52:35 +12:00
Henning Petersen
0ec2f8bc61 Fix: Add dot stuffing for POP3 (#300)
Co-authored-by: Henning Petersen <henning.petersen1@dhl.com>
2024-05-18 23:45:06 +12:00
Ralph Slooten
ed4618a1f3 Feature: iCalendar (ICS) viewer (#298) 2024-05-18 23:42:06 +12:00
Ralph Slooten
09f50f64fd Merge tag 'v1.18.2' into develop
Release v1.18.2
2024-05-15 16:15:52 +12:00
Ralph Slooten
f87ec396c9 Merge branch 'release/v1.18.2' 2024-05-15 16:12:02 +12:00
Ralph Slooten
3e37293c99 Release v1.18.2 2024-05-15 16:12:02 +12:00
Ralph Slooten
7147032c6b Chore: Update node dependencies 2024-05-15 16:10:48 +12:00
Ralph Slooten
3c36951113 Fix: Replace invalid Windows username characters in sendmail (#294) 2024-05-15 16:09:36 +12:00
Ralph Slooten
86e8a126ca Merge tag 'v1.18.1' into develop
Release v1.18.1
2024-05-09 17:03:21 +12:00
Ralph Slooten
7f586e15cf Merge branch 'release/v1.18.1' 2024-05-09 17:03:16 +12:00
Ralph Slooten
2a5559f5f0 Release v1.18.1 2024-05-09 17:03:16 +12:00
Ralph Slooten
ead3fad1dd Merge branch 'feature/smtp-message-id' into develop 2024-05-09 16:58:00 +12:00
Ralph Slooten
abd546133e Chore: Update node dependencies 2024-05-09 16:57:31 +12:00
Ralph Slooten
fae0384dfe Feature: Return queued Message ID in SMTP response (#293) 2024-05-09 16:56:39 +12:00
Ralph Slooten
aa1a5a0954 Chore: Update Go dependencies 2024-05-09 16:56:29 +12:00
Ralph Slooten
c81ea54c87 Remove redundant references to beta testing 2024-05-05 15:50:56 +12:00
Ralph Slooten
ebf7bb6348 Chore: Simplify JSON HTTP responses 2024-05-05 12:25:26 +12:00
Ralph Slooten
ba0e40fc7f Merge tag 'v1.18.0' into develop
Release v1.18.0
2024-05-04 11:18:40 +12:00
Ralph Slooten
9f0d393cee Merge branch 'release/v1.18.0' 2024-05-04 11:18:37 +12:00
Ralph Slooten
154cc5d392 Release v1.18.0 2024-05-04 11:18:37 +12:00
Ralph Slooten
4c31b49f18 Chore: Update node dependencies 2024-05-04 11:10:06 +12:00
Ralph Slooten
65adb6bc26 Chore: Update Go dependencies 2024-05-04 11:07:58 +12:00
Ralph Slooten
ea56cae43a Chore: Update go-release-action 2024-05-04 11:06:56 +12:00
Ralph Slooten
f424856685 Chore: JSON key case-consistency for posted API data (backwards-compatible) 2024-05-04 11:05:07 +12:00
Ralph Slooten
22d28a7b18 Chore: Remove function duplication - use common tools.InArray() 2024-05-04 10:20:46 +12:00
Ralph Slooten
a15f032b32 Feature: API endpoint for sending (#278) 2024-05-04 10:15:30 +12:00
Ralph Slooten
fce486553b Update screenshot 2024-04-26 16:11:54 +12:00
Ralph Slooten
96d0febd0e Merge branch 'feature/tag-filters' into develop 2024-04-26 14:52:21 +12:00
Ralph Slooten
dddc52a668 Feature: Set tagging filters via a config file 2024-04-26 14:52:10 +12:00
Ralph Slooten
65fb188586 Do not export autoTag struct 2024-04-25 23:18:46 +12:00
Ralph Slooten
15a5910695 Feature: Search filter support for auto-tagging 2024-04-25 23:04:35 +12:00
Ralph Slooten
6585d450c0 Feature: New search filter prefix addressed: includes From, To, Cc, Bcc & Reply-To 2024-04-25 22:13:57 +12:00
Ralph Slooten
1af32ebf8f Chore: Improve tag sorting in web UI, ignore casing 2024-04-25 14:45:36 +12:00
Ralph Slooten
5f2e548ba6 Merge branch 'feature/dayjs' into develop 2024-04-24 19:21:08 +12:00
Ralph Slooten
3b8eb44490 Chore: Replace moment JS library with dayjs 2024-04-24 19:19:37 +12:00
Ralph Slooten
8b067765e9 Chore: Auto-update relative received message times 2024-04-24 19:18:22 +12:00
Ralph Slooten
26ce538c45 Merge tag 'v1.17.1' into develop
Release v1.17.1
2024-04-24 16:53:19 +12:00
Ralph Slooten
a5cbba3de7 Merge branch 'release/v1.17.1' 2024-04-24 16:51:25 +12:00
Ralph Slooten
b5af86ddae Release v1.17.1 2024-04-24 16:51:25 +12:00
Ralph Slooten
800ceaebfe Chore: Update node dependencies 2024-04-24 16:01:23 +12:00
Ralph Slooten
3663b974c1 Chore: Update Go dependencies 2024-04-24 15:59:44 +12:00
Ralph Slooten
d381389fc9 Fix: Prevent error when two identical tags are added at the exact same time (#283) 2024-04-24 15:58:01 +12:00
Ralph Slooten
d3b048e933 Chore: Clearer error messages for read/write permission failures (#281) 2024-04-21 10:16:59 +12:00
Ralph Slooten
4cf90820ba Merge tag 'v1.17.0' into develop
Release v1.17.0
2024-04-21 00:18:13 +12:00
Ralph Slooten
7fe47d2bbc Merge branch 'release/v1.17.0' 2024-04-21 00:18:09 +12:00
Ralph Slooten
76afdefdc7 Release v1.17.0 2024-04-21 00:18:07 +12:00
Ralph Slooten
c878619484 Chore: Update caniemail database 2024-04-21 00:08:01 +12:00
Ralph Slooten
cfdeda68dd Chore: Update node dependencies 2024-04-21 00:07:11 +12:00
Ralph Slooten
3354370041 Chore: Update Go dependencies 2024-04-21 00:03:55 +12:00
Ralph Slooten
be67a35e03 Minor UI dropdown tweak 2024-04-20 23:58:05 +12:00
Ralph Slooten
cbcf0be1a2 Feature: Option to auto relay for matching recipient expression only (#274) 2024-04-20 23:42:36 +12:00
Ralph Slooten
18e4768739 Reduce stale issue duration 2024-04-20 14:40:51 +12:00
Ralph Slooten
072db266be Fix: Add delay to close database on fatal exit (#280) 2024-04-20 10:28:12 +12:00
Ralph Slooten
5ad76cb3a7 Fix typo 2024-04-18 19:32:09 +12:00
Ralph Slooten
96c33b1233 Chore: Auto-rotate thumbnail images based on exif data 2024-04-18 18:04:43 +12:00
Ralph Slooten
7085690e3d Only compile SMTPRelayConfig.AllowedRecipients if set 2024-04-16 22:15:09 +12:00
Ralph Slooten
7f430d3a45 Chore: Replace disintegration/imaging with kovidgoyal/imaging to fix CVE-2023-36308
This also bumps the minimum Go version to 1.21.0
2024-04-14 21:53:30 +12:00
Ralph Slooten
8da8a1ad6b Chore: Update API documentation regarding date/time searches & timezones 2024-04-14 12:36:45 +12:00
Ralph Slooten
9e527adb24 Merge branch 'feature/settings' into develop 2024-04-13 00:33:21 +12:00
Ralph Slooten
845fe840d4 Chore: Move Link check & HTML check features out of beta 2024-04-13 00:29:23 +12:00
Ralph Slooten
31e4f84f9a Chore: Remove deprecated --disable-html-check option 2024-04-13 00:25:48 +12:00
Ralph Slooten
faded05e47 Feature: Add UI settings screen 2024-04-13 00:25:04 +12:00
Ralph Slooten
a05e4fd48f Merge tag 'v1.16.0' into develop
Release v1.16.0
2024-04-12 15:19:47 +12:00
Ralph Slooten
affe19beb5 Merge branch 'release/v1.16.0' 2024-04-12 15:19:46 +12:00
Ralph Slooten
eefa4f868e Release v1.16.0 2024-04-12 15:19:45 +12:00
Ralph Slooten
81d434c848 Chore: Update caniemail test database 2024-04-12 14:53:46 +12:00
Ralph Slooten
86902ca52c Chore: Update node dependencies 2024-04-12 14:52:58 +12:00
Ralph Slooten
ca5b2a6377 Chore: Update Go dependencies 2024-04-12 14:50:40 +12:00
Ralph Slooten
a5c7ae34e3 Merge branch 'feature/date-search' into develop 2024-04-12 14:47:56 +12:00
Ralph Slooten
48c73ae97b Chore: Switch database flag/env to --database / MP_DATABASE
The original `--db-file` / `MP_DATA_FILE`, although deprecated, won't be removed any time soon to ensure backwards compatibility with existing integrations
2024-04-12 14:47:47 +12:00
Ralph Slooten
a7dfbf4af0 Feature: Search support for before: and after: dates (#252) 2024-04-12 14:44:14 +12:00
Maximilian Krauß
186f8b1829 Fix: Remove duplicated authentication check (#276) 2024-04-09 21:51:17 +12:00
Ralph Slooten
6a62890445 Fix Windows embed.FS path 2024-04-09 21:43:27 +12:00
Ralph Slooten
6a410a28b6 Feature: Add optional tenant ID to isolate data in shared databases (#254) 2024-04-09 21:30:56 +12:00
Ralph Slooten
94b4618420 Fix: Prevent conditional JS error when global mailbox tag list is modified via auto/plus-address tagging while viewing a message 2024-04-05 16:48:27 +13:00
Ralph Slooten
840a0cd3b8 Merge branch 'feature/rqlite' into develop 2024-04-05 15:48:45 +13:00
Ralph Slooten
254b2dd8ec Feature: Option to use rqlite database storage (#254) 2024-04-05 15:48:32 +13:00
Ralph Slooten
5166a761ec Fix: Extract plus addresses from email addresses only, not names 2024-04-01 18:16:09 +13:00
Ralph Slooten
cb34e1f561 Merge tag 'v1.15.1' into develop
Release v1.15.1
2024-03-31 00:11:51 +13:00
Ralph Slooten
ebe9195075 Merge branch 'release/v1.15.1' 2024-03-31 00:11:48 +13:00
Ralph Slooten
6a27e230a1 Release v1.15.1 2024-03-31 00:11:46 +13:00
Ralph Slooten
a805567810 Feature: Add readyz subcommand for Docker healthcheck (#270) 2024-03-31 00:06:25 +13:00
Ralph Slooten
83c70aa7c1 Chore: Code cleanup, remove redundant functionality 2024-03-24 21:37:37 +13:00
Ralph Slooten
61241f11ac Chore: Add labels to Docker image (#267) 2024-03-23 16:42:18 +13:00
Ralph Slooten
ebf4e6db5c Merge tag 'v1.15.0' into develop
Release v1.15.0
2024-03-17 15:06:21 +13:00
106 changed files with 6685 additions and 3354 deletions

View File

@@ -17,6 +17,24 @@ options:
fix: Fix
# perf: Performance Improvements
# refactor: Code Refactoring
sort_by: Custom
title_order:
- Feature
- Chore
- UI
- API
- Libs
- Docker
- Security
- Fix
- Bugfix
- Docs
- Swagger
- Build
- Testing
- Test
- Tests
- Pull Requests
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:

View File

@@ -16,19 +16,30 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ github.sha }}"
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge
ghcr.io/${{ github.repository }}:edge

View File

@@ -16,12 +16,19 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Parse semver
id: semver_parser
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
@@ -30,10 +37,9 @@ jobs:
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ github.ref_name }}"
@@ -42,3 +48,6 @@ jobs:
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}

View File

@@ -12,12 +12,12 @@ jobs:
steps:
- uses: actions/stale@v9.0.0
with:
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-issue-stale: 7
days-before-issue-close: 3
exempt-issue-labels: "enhancement,bug,javascript,docker"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 14 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -33,7 +33,7 @@ jobs:
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.49
- uses: wangyoucao577/go-release-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}

View File

@@ -26,8 +26,8 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./internal/storage ./server ./internal/tools ./internal/html2text -v
- run: go test ./internal/storage ./internal/html2text -bench=.
- run: go test -p 1 ./internal/storage ./server ./server/pop3 ./internal/tools ./internal/html2text -v
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets
- name: Build web UI

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM golang:alpine as builder
FROM golang:alpine AS builder
ARG VERSION=dev
@@ -12,10 +12,19 @@ CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Vers
FROM alpine:latest
LABEL org.opencontainers.image.title="Mailpit" \
org.opencontainers.image.description="An email and SMTP testing tool with API for developers" \
org.opencontainers.image.source="https://github.com/axllent/mailpit" \
org.opencontainers.image.url="https://mailpit.axllent.org" \
org.opencontainers.image.documentation="https://mailpit.axllent.org/docs/" \
org.opencontainers.image.licenses="MIT"
COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s CMD /mailpit readyz
ENTRYPOINT ["/mailpit"]

View File

@@ -49,9 +49,7 @@ The --recent flag will only consider files with a modification date within the l
return nil
}
info.ModTime()
if ingestRecent > 0 && time.Now().Sub(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
if ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
return nil
}

76
cmd/readyz.go Normal file
View File

@@ -0,0 +1,76 @@
package cmd
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/spf13/cobra"
)
var (
useHTTPS bool
)
// readyzCmd represents the healthcheck command
var readyzCmd = &cobra.Command{
Use: "readyz",
Short: "Run a healthcheck to test if Mailpit is running",
Long: `This command connects to the /readyz endpoint of a running Mailpit server
and exits with a status of 0 if the connection is successful, else with a
status 1 if unhealthy.
If running within Docker, it should automatically detect environment
settings to determine the HTTP bind interface & port.
`,
Run: func(cmd *cobra.Command, args []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
proto = "https"
}
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
conf := &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// do not verify TLS in case this instance is using HTTPS
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}
client := &http.Client{Transport: conf}
res, err := client.Get(uri)
if err != nil || res.StatusCode != 200 {
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(readyzCmd)
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
if config.UITLSCert != "" {
useHTTPS = true
}
readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port")
readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)")
}

View File

@@ -19,7 +19,7 @@ If you have several thousand messages in your mailbox, then it is advised to shu
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.Database = args[0]
config.MaxMessages = 0
if err := storage.InitDB(); err != nil {

View File

@@ -17,8 +17,6 @@ import (
"github.com/spf13/cobra"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mailpit",
@@ -36,14 +34,15 @@ Documentation:
os.Exit(1)
}
if err := storage.InitDB(); err != nil {
logger.Log().Error(err.Error())
logger.Log().Fatal(err.Error())
os.Exit(1)
}
go server.Listen()
if err := smtpd.Listen(); err != nil {
logger.Log().Error(err.Error())
storage.Close()
logger.Log().Fatal(err.Error())
os.Exit(1)
}
},
@@ -82,7 +81,9 @@ func init() {
// load environment variables
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().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)")
@@ -91,13 +92,12 @@ func init() {
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
// Web UI / API
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface & port for UI")
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
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(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
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.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
@@ -117,8 +117,9 @@ func init() {
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
// SMTP relay
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().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP relay configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
// POP3 server
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
@@ -127,13 +128,19 @@ func init() {
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")
// Tagging
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "Convert new tags automatically to TitleCase")
rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
rootCmd.Flags().Lookup("db-file").Hidden = true
// 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")
@@ -152,12 +159,23 @@ func init() {
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-tls-required", config.SMTPRequireSTARTTLS, "smtp-require-starttls")
rootCmd.Flags().Lookup("smtp-tls-required").Hidden = true
rootCmd.Flags().Lookup("smtp-tls-required").Deprecated = "use --smtp-require-starttls"
// DEPRECATED FLAG 2024/04/13 - no longer used
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
rootCmd.Flags().Lookup("disable-html-check").Hidden = true
}
// Load settings from environment
func initConfigFromEnv() {
// General
config.DataFile = os.Getenv("MP_DATA_FILE")
if len(os.Getenv("MP_DATABASE")) > 0 {
config.Database = os.Getenv("MP_DATABASE")
}
config.TenantID = os.Getenv("MP_TENANT_ID")
config.Label = os.Getenv("MP_LABEL")
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
@@ -193,9 +211,6 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
config.DisableHTMLCheck = true
}
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
@@ -225,7 +240,6 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") {
config.SMTPRequireTLS = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
@@ -245,8 +259,9 @@ func initConfigFromEnv() {
// SMTP relay
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAllIncoming = true
config.SMTPRelayAll = true
}
config.SMTPRelayMatching = os.Getenv("MP_SMTP_RELAY_MATCHING")
config.SMTPRelayConfig = config.SMTPRelayConfigStruct{}
config.SMTPRelayConfig.Host = os.Getenv("MP_SMTP_RELAY_HOST")
if len(os.Getenv("MP_SMTP_RELAY_PORT")) > 0 {
@@ -260,6 +275,7 @@ func initConfigFromEnv() {
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
// POP3 server
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
@@ -273,12 +289,10 @@ func initConfigFromEnv() {
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
// Tagging
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
if getEnabledFromEnv("MP_TAGS_TITLE_CASE") {
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
}
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
// Webhook
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
@@ -291,6 +305,11 @@ func initConfigFromEnv() {
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.Database = os.Getenv("MP_DATA_FILE")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
@@ -321,6 +340,10 @@ func initDeprecatedConfigFromEnv() {
logger.Log().Warn("ENV MP_SMTP_TLS_REQUIRED has been deprecated, use MP_SMTP_REQUIRE_STARTTLS")
config.SMTPRequireSTARTTLS = true
}
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
logger.Log().Warn("ENV MP_DISABLE_HTML_CHECK has been deprecated and is no longer used")
config.DisableHTMLCheck = true
}
}
// Wrapper to get a boolean from an environment variable

View File

@@ -26,8 +26,16 @@ var (
// HTTPListen to listen on <interface>:<port>
HTTPListen = "[::]:8025"
// DataFile for mail (optional)
DataFile string
// Database for mail (optional)
Database string
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label = ""
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
@@ -79,20 +87,24 @@ var (
// 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
// CLITagsArg is used to map the CLI args
CLITagsArg string
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
// TagsConfig is a yaml file to pre-load tags
TagsConfig string
// TagFilters are used to apply tags to new mail
TagFilters []autoTag
// TagsDisable accepts a comma-separated list of tag types to disable
// including x-tags & plus-addresses
TagsDisable string
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
@@ -113,9 +125,15 @@ var (
// 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.
// SMTPRelayAll is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAllIncoming = false
SMTPRelayAll = false
// SMTPRelayMatching if set, will auto-release to recipients matching this regular expression
SMTPRelayMatching string
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
SMTPRelayMatchingRegexp *regexp.Regexp
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
POP3Listen = "[::]:1110"
@@ -149,12 +167,15 @@ var (
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
DisableHTMLCheck = false
)
// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
type autoTag struct {
Match string
Tags []string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
@@ -170,6 +191,9 @@ type SMTPRelayConfigStruct struct {
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
// DEPRECATED 2024/03/12
RecipientAllowlist string `yaml:"recipient-allowlist"`
}
@@ -185,8 +209,20 @@ func VerifyConfig() error {
cssFontRestriction, cssFontRestriction,
)
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
if Database != "" && isDir(Database) {
Database = filepath.Join(Database, "mailpit.db")
}
Label = tools.Normalize(Label)
TenantID = tools.Normalize(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
TenantID = re.ReplaceAllString(TenantID, "_")
if !strings.HasSuffix(TenantID, "_") {
TenantID = TenantID + "_"
}
}
re := regexp.MustCompile(`.*:\d+$`)
@@ -201,7 +237,7 @@ func VerifyConfig() error {
UIAuthFile = filepath.Clean(UIAuthFile)
if !isFile(UIAuthFile) {
return fmt.Errorf("[ui] HTTP password file not found: %s", UIAuthFile)
return fmt.Errorf("[ui] HTTP password file not found or readable: %s", UIAuthFile)
}
b, err := os.ReadFile(UIAuthFile)
@@ -223,11 +259,11 @@ func VerifyConfig() error {
UITLSKey = filepath.Clean(UITLSKey)
if !isFile(UITLSCert) {
return fmt.Errorf("[ui] TLS certificate not found: %s", UITLSCert)
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("[ui] TLS key not found: %s", UITLSKey)
return fmt.Errorf("[ui] TLS key not found or readable: %s", UITLSKey)
}
}
@@ -240,11 +276,11 @@ func VerifyConfig() error {
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
if !isFile(SMTPTLSCert) {
return fmt.Errorf("[smtp] TLS certificate not found: %s", SMTPTLSCert)
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
}
if !isFile(SMTPTLSKey) {
return fmt.Errorf("[smtp] TLS key not found: %s", SMTPTLSKey)
return fmt.Errorf("[smtp] TLS key not found or readable: %s", SMTPTLSKey)
}
} else if SMTPRequireTLS {
return errors.New("[smtp] TLS cannot be required without an SMTP TLS certificate and key")
@@ -262,7 +298,7 @@ func VerifyConfig() error {
SMTPAuthFile = filepath.Clean(SMTPAuthFile)
if !isFile(SMTPAuthFile) {
return fmt.Errorf("[smtp] password file not found: %s", SMTPAuthFile)
return fmt.Errorf("[smtp] password file not found or readable: %s", SMTPAuthFile)
}
b, err := os.ReadFile(SMTPAuthFile)
@@ -301,11 +337,11 @@ func VerifyConfig() error {
POP3TLSKey = filepath.Clean(POP3TLSKey)
if !isFile(POP3TLSCert) {
return fmt.Errorf("[pop3] TLS certificate not found: %s", POP3TLSCert)
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
}
if !isFile(POP3TLSKey) {
return fmt.Errorf("[pop3] TLS key not found: %s", POP3TLSKey)
return fmt.Errorf("[pop3] TLS key not found or readable: %s", POP3TLSKey)
}
}
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
@@ -321,7 +357,7 @@ func VerifyConfig() error {
POP3AuthFile = filepath.Clean(POP3AuthFile)
if !isFile(POP3AuthFile) {
return fmt.Errorf("[pop3] password file not found: %s", POP3AuthFile)
return fmt.Errorf("[pop3] password file not found or readable: %s", POP3AuthFile)
}
b, err := os.ReadFile(POP3AuthFile)
@@ -347,6 +383,11 @@ func VerifyConfig() error {
return fmt.Errorf("webhook URL does not appear to be a valid URL (%s)", WebhookURL)
}
// DEPRECATED 2024/04/13
if DisableHTMLCheck {
logger.Log().Warn("--disable-html-check has been deprecated and is no longer used")
}
if EnableSpamAssassin != "" {
spamassassin.SetService(EnableSpamAssassin)
logger.Log().Infof("[spamassassin] enabled via %s", EnableSpamAssassin)
@@ -356,27 +397,16 @@ func VerifyConfig() error {
}
}
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("[tag] 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("[tag] invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
// load tag filters & options
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
}
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}
if err := parseTagsDisable(TagsDisable); err != nil {
return err
}
if SMTPAllowedRecipients != "" {
@@ -386,7 +416,7 @@ func VerifyConfig() error {
}
SMTPAllowedRecipientsRegexp = restrictRegexp
logger.Log().Infof("[smtp] only allowing recipients matching the following regexp: %s", SMTPAllowedRecipients)
logger.Log().Infof("[smtp] only allowing recipients matching regexp: %s", SMTPAllowedRecipients)
}
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
@@ -398,13 +428,28 @@ func VerifyConfig() error {
return err
}
if !ReleaseEnabled && SMTPRelayAllIncoming {
return errors.New("[smtp] relay config must be set to relay all messages")
if !ReleaseEnabled && SMTPRelayAll || !ReleaseEnabled && SMTPRelayMatching != "" {
return errors.New("[relay] a relay configuration must be set to auto-relay any messages")
}
if SMTPRelayAllIncoming {
if SMTPRelayMatching != "" {
if SMTPRelayAll {
logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled")
} else {
re, err := regexp.Compile(SMTPRelayMatching)
if err != nil {
return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error())
}
SMTPRelayMatchingRegexp = re
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
}
if SMTPRelayAll {
// this deserves a warning
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
return nil
@@ -419,7 +464,7 @@ func parseRelayConfig(c string) error {
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[smtp] relay configuration not found: %s", c)
return fmt.Errorf("[smtp] relay configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
@@ -437,7 +482,7 @@ func parseRelayConfig(c string) error {
// DEPRECATED 2024/03/12
if SMTPRelayConfig.RecipientAllowlist != "" {
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed_recipients' instead")
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
if SMTPRelayConfig.AllowedRecipients == "" {
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
}
@@ -482,35 +527,40 @@ func validateRelayConfig() error {
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if SMTPRelayConfig.AllowedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = allowlistRegexp
SMTPRelayConfig.AllowedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
}
if SMTPRelayConfig.BlockedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
}
SMTPRelayConfig.BlockedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
}
return nil
}
// IsFile returns if a path is a file
// IsFile returns whether a file exists and is readable
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
f, err := os.Open(filepath.Clean(path))
defer f.Close()
return err == nil
}
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
if err != nil || os.IsNotExist(err) || !info.IsDir() {
return false
}

111
config/tags.go Normal file
View File

@@ -0,0 +1,111 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
var (
// TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig()
TagsDisablePlus bool
// TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig()
TagsDisableXTags bool
)
type yamlTags struct {
Filters []yamlTag `yaml:"filters"`
}
type yamlTag struct {
Match string `yaml:"match"`
Tags string `yaml:"tags"`
}
// Load tags from a configuration from a file, if set
func loadTagsFromConfig(c string) error {
if c == "" {
return nil // not set, ignore
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return fmt.Errorf("[tags] %s", err.Error())
}
conf := yamlTags{}
if err := yaml.Unmarshal(data, &conf); err != nil {
return err
}
if conf.Filters == nil {
return fmt.Errorf("[tags] missing tag: array in %s", c)
}
for _, t := range conf.Filters {
tags := strings.Split(t.Tags, ",")
TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})
}
logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c)
return nil
}
func loadTagsFromArgs(c string) error {
if c == "" {
return nil // not set, ignore
}
args := tools.ArgsParser(c)
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
tags := strings.Split(t[0], ",")
TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters"))
return nil
}
func parseTagsDisable(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(strings.ToLower(s), ",")
for _, p := range parts {
switch strings.TrimSpace(p) {
case "x-tags", "xtags":
TagsDisableXTags = true
case "plus-addresses", "plus-addressing":
TagsDisablePlus = true
default:
return fmt.Errorf("[tags] invalid --tags-disable option: %s", p)
}
}
return nil
}

41
go.mod
View File

@@ -1,39 +1,40 @@
module github.com/axllent/mailpit
go 1.20
go 1.21.0
toolchain go1.22.1
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/PuerkitoBio/goquery v1.9.1
github.com/PuerkitoBio/goquery v1.9.2
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.2.0
github.com/klauspost/compress v1.17.7
github.com/klauspost/compress v1.17.9
github.com/kovidgoyal/imaging v1.6.3
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0
github.com/mhale/smtpd v0.8.2
github.com/mhale/smtpd v0.8.3
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.2
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.22.0
golang.org/x/text v0.14.0
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.27.0
golang.org/x/text v0.16.0
golang.org/x/time v0.5.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5
modernc.org/sqlite v1.30.2
)
require (
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cznic/ql v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -53,14 +54,14 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.22.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.45.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

141
go.sum
View File

@@ -1,68 +1,41 @@
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.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/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/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/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 h1:saBP362Qm7zDdDXqv61kI4rzhmLFq3Z1gx34xpl6cWE=
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -71,8 +44,10 @@ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQykt
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -87,12 +62,13 @@ github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQ
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mhale/smtpd v0.8.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -107,16 +83,20 @@ github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl
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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -130,6 +110,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
@@ -137,38 +118,38 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
github.com/vanng822/go-premailer v1.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
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.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -177,26 +158,34 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -206,22 +195,28 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.19.3 h1:vE9kmJqUcyvNOf8F2Hn8od14SOMq34BiqcZ2tMzLk5c=
modernc.org/ccgo/v4 v4.11.0 h1:2uc2kRvZLC/oHylsrirRW6f1I4wljQST2BBbm+aKiXM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.45.0 h1:qmAJZf9tYFqK/SFSFqpBc9uHWGsvoYWtRcMQdG+JEfM=
modernc.org/libc v1.45.0/go.mod h1:YkRHLoN4L70OdO1cVmM83KZhRbRvsc3XogfVzbTXBwE=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
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/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.30.2 h1:IPVVkhLu5mMVnS1dQgh3h0SAACRWcVk7aoLP9Us3UCk=
modernc.org/sqlite v1.30.2/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/tools"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
@@ -136,12 +137,12 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
var y, n, p float32
for family, stats := range found.Stats {
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
if len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {
continue
}
for platform, clients := range stats.(map[string]interface{}) {
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]interface{}) {
@@ -182,18 +183,6 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
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)

View File

@@ -1,6 +1,10 @@
package htmlcheck
import "sort"
import (
"sort"
"github.com/axllent/mailpit/internal/tools"
)
// Platforms returns all platforms with their respective email clients
func Platforms() (map[string][]string, error) {
@@ -19,7 +23,7 @@ func Platforms() (map[string][]string, error) {
if !found {
data[platform] = []string{}
}
if !inArray(niceFamily, c) {
if !tools.InArray(niceFamily, c) {
c = append(c, niceFamily)
data[platform] = c
}

View File

@@ -32,9 +32,7 @@ func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
func extractTextLinks(msg *storage.Message) []string {
links := []string{}
for _, match := range linkRe.FindAllString(msg.Text, -1) {
links = append(links, match)
}
links = append(links, linkRe.FindAllString(msg.Text, -1)...)
return links
}

View File

@@ -0,0 +1,448 @@
// Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies.
// This is used solely for testing the POP3 server
package pop3client
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"strconv"
"strings"
"time"
)
// Client implements a Client e-mail client.
type Client struct {
opt Opt
dialer Dialer
}
// Conn is a stateful connection with the POP3 server/
type Conn struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
// Opt represents the client configuration.
type Opt struct {
Host string `json:"host"`
Port int `json:"port"`
// Default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
Dialer Dialer `json:"-"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
}
// Dialer interface
type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// MessageID contains the ID and size of an individual message.
type MessageID struct {
// ID is the numerical index (non-unique) of the message.
ID int
Size int
// UID is only present if the response is to the UIDL command.
UID string
}
var (
lineBreak = []byte("\r\n")
respOK = []byte("+OK") // `+OK` without additional info
respOKInfo = []byte("+OK ") // `+OK <info>`
respErr = []byte("-ERR") // `-ERR` without additional info
respErrInfo = []byte("-ERR ") // `-ERR <info>`
)
// New returns a new client object using an existing connection.
func New(opt Opt) *Client {
if opt.DialTimeout < time.Millisecond {
opt.DialTimeout = time.Second * 3
}
c := &Client{
opt: opt,
dialer: opt.Dialer,
}
if c.dialer == nil {
c.dialer = &net.Dialer{Timeout: opt.DialTimeout}
}
return c
}
// NewConn creates and returns live POP3 server connection.
func (c *Client) NewConn() (*Conn, error) {
var (
addr = fmt.Sprintf("%s:%d", c.opt.Host, c.opt.Port)
)
conn, err := c.dialer.Dial("tcp", addr)
if err != nil {
return nil, err
}
// No TLS.
if c.opt.TLSEnabled {
// Skip TLS host verification.
tlsCfg := tls.Config{} // #nosec
if c.opt.TLSSkipVerify {
tlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec
} else {
tlsCfg.ServerName = c.opt.Host
}
conn = tls.Client(conn, &tlsCfg)
}
pCon := &Conn{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
// Verify the connection by reading the welcome +OK greeting.
if _, err := pCon.ReadOne(); err != nil {
return nil, err
}
return pCon, nil
}
// Send sends a POP3 command to the server. The given comand is suffixed with "\r\n".
func (c *Conn) Send(b string) error {
if _, err := c.w.WriteString(b + "\r\n"); err != nil {
return err
}
return c.w.Flush()
}
// Cmd sends a command to the server. POP3 responses are either single line or multi-line.
// The first line always with -ERR in case of an error or +OK in case of a successful operation.
// OK+ is always followed by a response on the same line which is either the actual response data
// in case of single line responses, or a help message followed by multiple lines of actual response
// data in case of multiline responses.
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
func (c *Conn) Cmd(cmd string, isMulti bool, args ...interface{}) (*bytes.Buffer, error) {
var cmdLine string
// Repeat a %v to format each arg.
if len(args) > 0 {
format := " " + strings.TrimRight(strings.Repeat("%v ", len(args)), " ")
// CMD arg1 argn ...\r\n
cmdLine = fmt.Sprintf(cmd+format, args...)
} else {
cmdLine = cmd
}
if err := c.Send(cmdLine); err != nil {
return nil, err
}
// Read the first line of response to get the +OK/-ERR status.
b, err := c.ReadOne()
if err != nil {
return nil, err
}
// Single line response.
if !isMulti {
return bytes.NewBuffer(b), err
}
buf, err := c.ReadAll()
return buf, err
}
// ReadOne reads a single line response from the conn.
func (c *Conn) ReadOne() ([]byte, error) {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
r, err := parseResp(b)
return r, err
}
// ReadAll reads all lines from the connection until the POP3 multiline terminator "." is encountered
// and returns a bytes.Buffer of all the read lines.
func (c *Conn) ReadAll() (*bytes.Buffer, error) {
buf := &bytes.Buffer{}
for {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
// "." indicates the end of a multi-line response.
if bytes.Equal(b, []byte(".")) {
break
}
if _, err := buf.Write(b); err != nil {
return nil, err
}
if _, err := buf.Write(lineBreak); err != nil {
return nil, err
}
}
return buf, nil
}
// Auth authenticates the given credentials with the server.
func (c *Conn) Auth(user, password string) error {
if err := c.User(user); err != nil {
return err
}
if err := c.Pass(password); err != nil {
return err
}
// Issue a NOOP to force the server to respond to the auth.
// Courtesy: github.com/TheCreeper/go-pop3
return c.Noop()
}
// User sends the username to the server.
func (c *Conn) User(s string) error {
_, err := c.Cmd("USER", false, s)
return err
}
// Pass sends the password to the server.
func (c *Conn) Pass(s string) error {
_, err := c.Cmd("PASS", false, s)
return err
}
// Stat returns the number of messages and their total size in bytes in the inbox.
func (c *Conn) Stat() (int, int, error) {
b, err := c.Cmd("STAT", false)
if err != nil {
return 0, 0, err
}
// count size
f := bytes.Fields(b.Bytes())
// Total number of messages.
count, err := strconv.Atoi(string(f[0]))
if err != nil {
return 0, 0, err
}
if count == 0 {
return 0, 0, nil
}
// Total size of all messages in bytes.
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return 0, 0, err
}
return count, size, nil
}
// List returns a list of (message ID, message Size) pairs.
// If the optional msgID > 0, then only that particular message is listed.
// The message IDs are sequential, 1 to N.
func (c *Conn) List(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("LIST", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("LIST", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, Size: size})
}
return out, nil
}
// Uidl returns a list of (message ID, message UID) pairs. If the optional msgID
// is > 0, then only that particular message is listed. It works like Top() but only works on
// servers that support the UIDL command. Messages size field is not available in the UIDL response.
func (c *Conn) Uidl(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("UIDL", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("UIDL", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, UID: string(f[1])})
}
return out, nil
}
// Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message.
func (c *Conn) Retr(msgID int) (*mail.Message, error) {
b, err := c.Cmd("RETR", true, msgID)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// RetrRaw downloads a message by the given msgID and returns the raw []byte
// of the entire message.
func (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) {
b, err := c.Cmd("RETR", true, msgID)
return b, err
}
// Top retrieves a message by its ID with full headers and numLines lines of the body.
func (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) {
b, err := c.Cmd("TOP", true, msgID, numLines)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// Dele deletes one or more messages. The server only executes the
// deletions after a successful Quit().
func (c *Conn) Dele(msgID ...int) error {
for _, id := range msgID {
_, err := c.Cmd("DELE", false, id)
if err != nil {
return err
}
}
return nil
}
// Rset clears the messages marked for deletion in the current session.
func (c *Conn) Rset() error {
_, err := c.Cmd("RSET", false)
return err
}
// Noop issues a do-nothing NOOP command to the server. This is useful for
// prolonging open connections.
func (c *Conn) Noop() error {
_, err := c.Cmd("NOOP", false)
return err
}
// Quit sends the QUIT command to server and gracefully closes the connection.
// Message deletions (DELE command) are only executed by the server on a graceful
// quit and close.
func (c *Conn) Quit() error {
defer c.conn.Close()
if _, err := c.Cmd("QUIT", false); err != nil {
return err
}
return nil
}
// parseResp checks if the response is an error that starts with `-ERR`
// and returns an error with the message that succeeds the error indicator.
// For success `+OK` messages, it returns the remaining response bytes.
func parseResp(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, nil
}
if bytes.Equal(b, respOK) {
return nil, nil
} else if bytes.HasPrefix(b, respOKInfo) {
return bytes.TrimPrefix(b, respOKInfo), nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if bytes.HasPrefix(b, respErrInfo) {
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
}

View File

@@ -2,7 +2,6 @@
package stats
import (
"os"
"runtime"
"sync"
"time"
@@ -21,10 +20,10 @@ var (
mu sync.RWMutex
smtpAccepted int
smtpAcceptedSize int
smtpRejected int
smtpIgnored int
smtpAccepted float64
smtpAcceptedSize float64
smtpRejected float64
smtpIgnored float64
)
// AppInformation struct
@@ -37,29 +36,29 @@ type AppInformation struct {
// Database path
Database string
// Database size in bytes
DatabaseSize int64
DatabaseSize float64
// Total number of messages in the database
Messages int
Messages float64
// Total number of messages in the database
Unread int
Unread float64
// Tags and message totals per tag
Tags map[string]int64
// Runtime statistics
RuntimeStats struct {
// Mailpit server uptime in seconds
Uptime int
Uptime float64
// Current memory usage in bytes
Memory uint64
// Database runtime messages deleted
MessagesDeleted int
MessagesDeleted float64
// Accepted runtime SMTP messages
SMTPAccepted int
SMTPAccepted float64
// Total runtime accepted messages size in bytes
SMTPAcceptedSize int
SMTPAcceptedSize float64
// Rejected runtime SMTP messages
SMTPRejected int
SMTPRejected float64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
SMTPIgnored int
SMTPIgnored float64
}
}
@@ -72,8 +71,7 @@ func Load() AppInformation {
runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
info.RuntimeStats.Uptime = int(time.Since(startedAt).Seconds())
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
info.RuntimeStats.SMTPAccepted = smtpAccepted
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
@@ -96,16 +94,10 @@ func Load() AppInformation {
}
}
info.Database = config.DataFile
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Database = config.Database
info.DatabaseSize = storage.DbSize()
info.Messages = storage.CountTotal()
info.Unread = storage.CountUnread()
info.Tags = storage.GetAllTagsCount()
return info
@@ -120,7 +112,7 @@ func Track() {
func LogSMTPAccepted(size int) {
mu.Lock()
smtpAccepted = smtpAccepted + 1
smtpAcceptedSize = smtpAcceptedSize + size
smtpAcceptedSize = smtpAcceptedSize + float64(size)
mu.Unlock()
}

View File

@@ -27,7 +27,7 @@ func dbCron() {
if deletedSize > 0 {
total := totalMessagesSize()
var deletedPercent int64
var deletedPercent float64
if total == 0 {
deletedPercent = 100
} else {
@@ -35,7 +35,7 @@ func dbCron() {
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
logger.Log().Debugf("[db] deleted messages is %d%% of total size, reclaim space", deletedPercent)
logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
vacuumDb()
}
}
@@ -55,15 +55,15 @@ func pruneMessages() {
start := time.Now()
q := sqlf.Select("ID, Size").
From("mailbox").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
var prunedSize int64
var size int
if err := q.Query(nil, db, func(row *sql.Rows) {
var size float64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
@@ -93,19 +93,19 @@ func pruneMessages() {
args[i] = id
}
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox_data")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
_, err = tx.Exec(`DELETE FROM `+tenant("message_tags")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Query(`DELETE FROM message_tags WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
@@ -137,6 +137,11 @@ func pruneMessages() {
// Vacuum the database to reclaim space from deleted messages
func vacuumDb() {
if sqlDriver == "rqlite" {
// let rqlite handle vacuuming
return
}
start := time.Now()
// set WAL file checkpoint
@@ -147,7 +152,7 @@ func vacuumDb() {
// vacuum database
if _, err := db.Exec("VACUUM"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
logger.Log().Errorf("[db] VACUUM: %s", err.Error())
return
}
@@ -162,5 +167,5 @@ func vacuumDb() {
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] vacuumed database in %s", elapsed)
logger.Log().Debugf("[db] vacuum completed in %s", elapsed)
}

View File

@@ -2,12 +2,14 @@
package storage
import (
"context"
"database/sql"
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
@@ -16,14 +18,18 @@ import (
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
// sqlite (native) - https://gitlab.com/cznic/sqlite
// sqlite - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite"
// rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/
_ "github.com/rqlite/gorqlite/stdlib"
)
var (
db *sql.DB
dbFile string
dbIsTemp bool
sqlDriver string
dbLastAction time.Time
// zstd compression encoder & decoder
@@ -33,46 +39,77 @@ var (
// InitDB will initialise the database
func InitDB() error {
p := config.DataFile
p := config.Database
var dsn string
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
sqlDriver = "sqlite"
dsn = p
logger.Log().Debugf("[db] using temporary database: %s", p)
} else if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
sqlDriver = "rqlite"
dsn = p
logger.Log().Debugf("[db] opening rqlite database %s", p)
} else {
p = filepath.Clean(p)
sqlDriver = "sqlite"
dsn = fmt.Sprintf("file:%s?cache=shared", p)
logger.Log().Debugf("[db] opening database %s", p)
}
config.DataFile = p
config.Database = p
logger.Log().Debugf("[db] opening database %s", p)
if sqlDriver == "sqlite" {
if !isFile(p) {
// try create a file to ensure permissions
f, err := os.Create(p)
if err != nil {
return fmt.Errorf("[db] %s", err.Error())
}
_ = f.Close()
}
}
var err error
dsn := fmt.Sprintf("file:%s?cache=shared", p)
db, err = sql.Open("sqlite", dsn)
db, err = sql.Open(sqlDriver, dsn)
if err != nil {
return err
}
for i := 1; i < 6; i++ {
if err := Ping(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
time.Sleep(5 * time.Second)
} else {
continue
}
}
// prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1)
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
if err != nil {
return err
if sqlDriver == "sqlite" {
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
if err != nil {
return err
}
}
// create tables if necessary & apply migrations
if err := dbApplyMigrations(); err != nil {
if err := dbApplySchemas(); err != nil {
return err
}
LoadTagFilters()
dbFile = p
dbLastAction = time.Now()
@@ -98,14 +135,25 @@ func InitDB() error {
return nil
}
// Close will close the database, and delete if a temporary table
// Tenant applies an optional prefix to the table name
func tenant(table string) string {
return fmt.Sprintf("%s%s", config.TenantID, table)
}
// Close will close the database, and delete if temporary
func Close() {
// on a fatal exit (eg: ports blocked), allow Mailpit to run migration tasks before closing the DB
time.Sleep(200 * time.Millisecond)
if db != nil {
if err := db.Close(); err != nil {
logger.Log().Warn("[db] error closing database, ignoring")
}
}
// allow SQLite to finish closing DB & write WAL logs if local
time.Sleep(100 * time.Millisecond)
if dbIsTemp && isFile(dbFile) {
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
if err := os.Remove(dbFile); err != nil {
@@ -114,6 +162,11 @@ func Close() {
}
}
// Ping the database connection and return an error if unsuccessful
func Ping() error {
return db.Ping()
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
@@ -132,53 +185,63 @@ func StatsGet() MailboxStats {
}
// CountTotal returns the number of emails in the database
func CountTotal() int {
var total int
func CountTotal() float64 {
var total float64
_ = sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
QueryRowAndClose(context.TODO(), db)
return total
}
// CountUnread returns the number of emails in the database that are unread.
func CountUnread() int {
var total int
func CountUnread() float64 {
var total float64
q := sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 0)
_ = q.QueryRowAndClose(nil, db)
Where("Read = ?", 0).
QueryRowAndClose(context.TODO(), db)
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() int {
var total int
func CountRead() float64 {
var total float64
q := sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 1)
_ = q.QueryRowAndClose(nil, db)
Where("Read = ?", 1).
QueryRowAndClose(context.TODO(), 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.
// DbSize returns the size of the SQLite database.
func DbSize() float64 {
var total sql.NullFloat64
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return total.Float64
}
return total.Float64
}
// IsUnread returns whether a message is unread or not.
func IsUnread(id string) bool {
var unread int
q := sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id)
_ = q.QueryRowAndClose(nil, db)
Where("ID = ?", id).
QueryRowAndClose(context.TODO(), db)
return unread == 1
}
@@ -187,11 +250,10 @@ func IsUnread(id string) bool {
func MessageIDExists(id string) bool {
var total int
q := sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("MessageID = ?", id)
_ = q.QueryRowAndClose(nil, db)
Where("MessageID = ?", id).
QueryRowAndClose(context.TODO(), db)
return total != 0
}

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -69,13 +71,6 @@ func Store(body *[]byte) (string, error) {
return "", err
}
// extract tags from body matches based on --tag, plus addresses & X-Tags header
tagStr := findTagsInRawMessage(body) + "," +
obj.tagsFromPlusAddresses() + "," +
strings.TrimSpace(env.Root.Header.Get("X-Tags"))
tagData := uniqueTagsFromString(tagStr)
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
@@ -88,21 +83,27 @@ func Store(body *[]byte) (string, error) {
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := len(*body)
size := float64(len(*body))
inline := len(env.Inlines)
attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML)
sql := fmt.Sprintf(`INSERT INTO %s
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
tenant("mailbox"),
) // #nosec
// insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) values(?,?,?,?,?,?,?,?,?,0,?)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
_, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
if err != nil {
return "", err
}
// insert compressed raw message
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size))
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
hexStr := hex.EncodeToString(encoded)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
if err != nil {
return "", err
}
@@ -111,9 +112,29 @@ func Store(body *[]byte) (string, error) {
return "", err
}
if len(tagData) > 0 {
// set tags after tx.Commit()
if err := SetMessageTags(id, tagData); err != nil {
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
if !config.TagsDisableXTags {
xTagsHdr := env.Root.Header.Get("X-Tags")
if xTagsHdr != "" {
// extract tags from X-Tags header
tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...)
}
}
if !config.TagsDisablePlus {
// get tags from plus-addresses
tags = append(tags, obj.tagsFromPlusAddresses()...)
}
// extract tags from search matches, and sort and extract unique tags
tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))
setTags := []string{}
if len(tags) > 0 {
setTags, err = SetMessageTags(id, tags)
if err != nil {
return "", err
}
}
@@ -129,7 +150,7 @@ func Store(body *[]byte) (string, error) {
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
c.Tags = setTags
c.Snippet = snippet
websockets.Broadcast("new", c)
@@ -148,19 +169,19 @@ func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
q := sqlf.From("mailbox m").
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
OrderBy("m.Created DESC").
Limit(limit).
Offset(start)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size int
var size float64
var attachments int
var read int
var snippet string
@@ -176,7 +197,7 @@ func List(start, limit int) ([]MessageSummary, error) {
return
}
em.Created = time.UnixMilli(created)
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
@@ -241,12 +262,12 @@ func GetMessage(id string) (*Message, error) {
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From("mailbox").
q := sqlf.From(tenant("mailbox")).
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
if err := row.Scan(&created); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -255,7 +276,7 @@ func GetMessage(id string) (*Message, error) {
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(created)
date = time.UnixMilli(int64(created))
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
@@ -273,7 +294,7 @@ func GetMessage(id string) (*Message, error) {
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: len(raw),
Size: float64(len(raw)),
Text: env.Text,
}
@@ -327,11 +348,10 @@ func GetMessage(id string) (*Message, error) {
func GetMessageRaw(id string) ([]byte, error) {
var i string
var msg string
q := sqlf.From("mailbox_data").
q := sqlf.From(tenant("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
@@ -341,7 +361,17 @@ func GetMessageRaw(id string) ([]byte, error) {
return nil, errors.New("message not found")
}
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
var data []byte
if sqlDriver == "rqlite" {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
}
} else {
data = []byte(msg)
}
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
@@ -398,7 +428,7 @@ func LatestID(r *http.Request) (string, error) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, 0, 1)
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
if err != nil {
return "", err
}
@@ -421,7 +451,7 @@ func MarkRead(id string) error {
return nil
}
_, err := sqlf.Update("mailbox").
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
@@ -442,7 +472,7 @@ func MarkAllRead() error {
total = CountUnread()
)
_, err := sqlf.Update("mailbox").
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
@@ -451,7 +481,7 @@ func MarkAllRead() error {
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed)
BroadcastMailboxStats()
@@ -467,7 +497,7 @@ func MarkAllUnread() error {
total = CountRead()
)
_, err := sqlf.Update("mailbox").
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
@@ -476,7 +506,7 @@ func MarkAllUnread() error {
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
@@ -491,7 +521,7 @@ func MarkUnread(id string) error {
return nil
}
_, err := sqlf.Update("mailbox").
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
@@ -507,52 +537,91 @@ func MarkUnread(id string) error {
return err
}
// DeleteOneMessage will delete a single message from a mailbox
func DeleteOneMessage(id string) error {
m, err := GetMessageRaw(id)
// DeleteMessages deletes one or more messages in bulk
func DeleteMessages(ids []string) error {
if len(ids) == 0 {
return nil
}
start := time.Now()
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec
rows, err := db.Query(sql, args...)
if err != nil {
return err
}
defer rows.Close()
toDelete := []string{}
var totalSize float64
for rows.Next() {
var id string
var size float64
if err := rows.Scan(&id, &size); err != nil {
return err
}
toDelete = append(toDelete, id)
totalSize = totalSize + size
}
if err = rows.Err(); err != nil {
return err
}
if len(toDelete) == 0 {
return nil // nothing to delete
}
size := len(m)
// 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
args = make([]interface{}, len(toDelete))
for i, id := range toDelete {
args[i] = id
}
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
if err != nil {
return err
tables := []string{"mailbox", "mailbox_data", "message_tags"}
for _, t := range tables {
sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1))
_, err = tx.Exec(sql, args...) // #nosec
if err != nil {
return err
}
}
err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted message %s", id)
}
if err := DeleteAllMessageTags(id); err != nil {
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(int64(size))
addDeletedSize(int64(totalSize))
logMessagesDeleted(1)
logMessagesDeleted(len(toDelete))
_ = pruneUnusedTags()
elapsed := time.Since(start)
messages := "messages"
if len(toDelete) == 1 {
messages = "message"
}
logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed)
BroadcastMailboxStats()
return err
return nil
}
// DeleteAllMessages will delete all messages from a mailbox
@@ -562,9 +631,9 @@ func DeleteAllMessages() error {
total int
)
_ = sqlf.From("mailbox").
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
QueryRowAndClose(context.TODO(), db)
// begin a transaction to ensure both the message
// summaries and data are deleted successfully
@@ -576,24 +645,14 @@ func DeleteAllMessages() error {
// roll back if it fails
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM mailbox")
if err != nil {
return err
}
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
_, err = tx.Exec("DELETE FROM mailbox_data")
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM tags")
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM message_tags")
if err != nil {
return err
for _, t := range tables {
sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec
_, err := tx.Exec(sql)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {

View File

@@ -13,8 +13,6 @@ func TestTextEmailInserts(t *testing.T) {
start := time.Now()
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err)
@@ -22,19 +20,17 @@ func TestTextEmailInserts(t *testing.T) {
}
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
assertEqual(t, CountTotal(), float64(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")
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
@@ -56,19 +52,17 @@ func TestMimeEmailInserts(t *testing.T) {
}
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
assertEqual(t, CountTotal(), float64(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")
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
}
@@ -107,14 +101,14 @@ func TestRetrieveMimeEmail(t *testing.T) {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
}
func TestMessageSummary(t *testing.T) {

View File

@@ -1,188 +0,0 @@
package storage
import (
"database/sql"
"encoding/json"
"github.com/GuiaBolso/darwin"
"github.com/axllent/mailpit/internal/logger"
"github.com/leporo/sqlf"
)
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 '';`,
},
{
Version: 1.4,
Description: "Create tag tables",
Script: `CREATE TABLE IF NOT EXISTS tags (
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Name TEXT COLLATE NOCASE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tag_name ON tags (Name);
CREATE TABLE IF NOT EXISTS message_tags(
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT REFERENCES mailbox(ID),
TagID INT REFERENCES tags(ID)
);
CREATE INDEX IF NOT EXISTS idx_message_tag_id ON message_tags (ID);
CREATE INDEX IF NOT EXISTS idx_message_tag_tagid ON message_tags (TagID);`,
},
{
// assume deleted messages account for 50% of storage
// to handle previously-deleted messages
Version: 1.5,
Description: "Create settings table",
Script: `CREATE TABLE IF NOT EXISTS settings (
Key TEXT,
Value TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_key ON settings (Key);
INSERT INTO settings (Key, Value) VALUES("DeletedSize", (SELECT SUM(Size)/2 FROM mailbox));`,
},
}
)
// 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()
}
// These functions are used to migrate data formats/structure on startup.
func dataMigrations() {
// ensure DeletedSize has a value if empty
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From("mailbox").
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update("mailbox").
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(nil, db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update("mailbox").
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(nil, db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

View File

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

View File

@@ -5,6 +5,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/mail"
"os"
@@ -24,9 +25,9 @@ func ReindexAll() {
finished := 0
err := sqlf.Select("ID").To(&i).
From("mailbox").
From(tenant("mailbox")).
OrderBy("Created DESC").
QueryAndClose(nil, db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
ids = append(ids, i)
})
@@ -112,7 +113,7 @@ func ReindexAll() {
// insert mail summary data
for _, u := range updates {
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?", u.SearchText, u.Snippet, u.Metadata, u.ID)
_, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue

224
internal/storage/schemas.go Normal file
View File

@@ -0,0 +1,224 @@
package storage
import (
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
"log"
"path"
"sort"
"strings"
"text/template"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
"github.com/leporo/sqlf"
)
//go:embed schemas/*
var schemaScripts embed.FS
// Create tables and apply schemas if required
func dbApplySchemas() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {
return err
}
var legacyMigrationTable int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable)
if err != nil {
return err
}
if legacyMigrationTable == 1 {
rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations"))
if err != nil {
return err
}
legacySchemas := []string{}
for rows.Next() {
var oldID string
if err := rows.Scan(&oldID); err == nil {
legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID))
}
}
legacySchemas = semver.SortMin(legacySchemas)
for _, v := range legacySchemas {
var migrated int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated)
if err != nil {
return err
}
if migrated == 0 {
// copy to tenant("schemas")
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil {
return err
}
}
}
// delete legacy migration database after 01/10/2024
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
return err
}
}
}
schemaFiles, err := schemaScripts.ReadDir("schemas")
if err != nil {
log.Fatal(err)
}
temp := template.New("")
temp.Funcs(
template.FuncMap{
"tenant": tenant,
},
)
type schema struct {
Name string
Semver string
}
scripts := []schema{}
for _, s := range schemaFiles {
if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") {
continue
}
schemaID := strings.TrimRight(s.Name(), ".sql")
if !semver.IsValid(schemaID) {
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())
continue
}
script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)}
scripts = append(scripts, script)
}
// sort schemas by semver, low to high
sort.Slice(scripts, func(i, j int) bool {
return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1
})
for _, s := range scripts {
var complete int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete)
if err != nil {
return err
}
if complete == 1 {
// already completed, ignore
continue
}
// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305
b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name))
if err != nil {
return err
}
// parse import script
t1, err := temp.Parse(string(b))
if err != nil {
return err
}
buf := new(bytes.Buffer)
if err := t1.Execute(buf, nil); err != nil {
return err
}
if _, err := db.Exec(buf.String()); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil {
return err
}
logger.Log().Debugf("[db] applied schema: %s", s.Name)
}
return nil
}
// These functions are used to migrate data formats/structure on startup.
func dataMigrations() {
// ensure DeletedSize has a value if empty
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// TODO: Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From(tenant("mailbox")).
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if _, err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

View File

@@ -0,0 +1,19 @@
-- CREATE TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID);

View File

@@ -0,0 +1,3 @@
-- CREATE TAGS COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@@ -0,0 +1,36 @@
-- CREATING NEW MAILBOX FORMAT
CREATE TABLE IF NOT EXISTS {{ tenant "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 {{ tenant "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 {{ tenant "mailbox" }};
DROP TABLE IF EXISTS {{ tenant "mailbox" }};
ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }};
CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@@ -0,0 +1,2 @@
-- CREATE SNIPPET COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,16 @@
-- CREATE TAG TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} (
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Name TEXT COLLATE NOCASE
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name);
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT REFERENCES {{ tenant "mailbox" }} (ID),
TagID INT REFERENCES {{ tenant "tags" }} (ID)
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID);

View File

@@ -0,0 +1,7 @@
-- CREATE SETTINGS TABLE
CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} (
Key TEXT,
Value TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key);
INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }}));

View File

@@ -0,0 +1,5 @@
# Migration scripts
- Scripts should be named using semver and have the `.sql` extension.
- Inline comments should be prefixed with a `--`
- All references to tables and indexes should be wrapped with a `{{ tenant "<name>" }}`

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
@@ -17,7 +18,7 @@ import (
// 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) {
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
@@ -26,16 +27,16 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
limit = 50
}
q := searchQueryBuilder(search)
q := searchQueryBuilder(search, timezone)
var err error
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size int
var size float64
var attachments int
var snippet string
var read int
@@ -52,7 +53,7 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
return
}
em.Created = time.UnixMilli(created)
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
@@ -95,21 +96,20 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
// 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)
func DeleteSearch(search, timezone string) error {
q := searchQueryBuilder(search, timezone)
ids := []string{}
deleteSize := 0
deleteSize := float64(0)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size int
var size float64
var attachments int
// var tags string
var read int
var snippet string
var ignore string
@@ -160,21 +160,21 @@ func DeleteSearch(search string) error {
delIDs[i] = id
}
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
sqlDelete1 := `DELETE FROM ` + tenant("mailbox") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil {
return err
}
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
sqlDelete2 := `DELETE FROM ` + tenant("mailbox_data") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete2, delIDs...)
if err != nil {
return err
}
sqlDelete3 := `DELETE FROM message_tags WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
sqlDelete3 := `DELETE FROM ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete3, delIDs...)
if err != nil {
@@ -204,11 +204,20 @@ func DeleteSearch(search string) error {
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString string) *sqlf.Stmt {
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
q := sqlf.From("mailbox m").
if timezone != "" {
loc, err := time.LoadLocation(timezone)
if err != nil {
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
} else {
time.Local = loc
}
}
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,
m.Snippet,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
@@ -285,6 +294,16 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "addressed:") {
w = cleanString(w[10:])
arg := "%" + escPercentChar(w) + "%"
if w != "" {
if exclude {
q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg)
} else {
q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg)
}
}
} else if strings.HasPrefix(lw, "subject:") {
w = w[8:]
if w != "" {
@@ -307,9 +326,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where(`m.ID NOT IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
q.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
} else {
q.Where(`m.ID IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
q.Where(`m.ID IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
}
}
} else if lw == "is:read" {
@@ -326,9 +345,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
}
} else if lw == "is:tagged" {
if exclude {
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
} else {
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
}
} else if lw == "has:attachment" || lw == "has:attachments" {
if exclude {
@@ -336,6 +355,36 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
} else {
q.Where("Attachments > 0")
}
} else if strings.HasPrefix(lw, "after:") {
w = cleanString(w[6:])
if w != "" {
t, err := dateparse.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created <= ?`, timestamp)
} else {
q.Where(`m.Created >= ?`, timestamp)
}
}
}
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
if w != "" {
t, err := dateparse.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created >= ?`, timestamp)
} else {
q.Where(`m.Created <= ?`, timestamp)
}
}
}
} else {
// search text
if exclude {

View File

@@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, 0, 100)
summaries, _, err := Search(search, "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", 0, testRuns)
summaries, _, err := Search("This is the email body", "", 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -109,7 +109,7 @@ func TestSearchDelete100(t *testing.T) {
}
}
_, total, err := Search("from:sender@example.com", 0, 100)
_, total, err := Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -117,12 +117,12 @@ func TestSearchDelete100(t *testing.T) {
assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil {
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", 0, 100)
_, total, err = Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -143,7 +143,7 @@ func TestSearchDelete1100(t *testing.T) {
}
}
_, total, err := Search("from:sender@example.com", 0, 100)
_, total, err := Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -151,12 +151,12 @@ func TestSearchDelete1100(t *testing.T) {
assertEqual(t, total, 1100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil {
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", 0, 100)
_, total, err = Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()

View File

@@ -1,6 +1,7 @@
package storage
import (
"context"
"database/sql"
"github.com/axllent/mailpit/internal/logger"
@@ -10,11 +11,11 @@ import (
// SettingGet returns a setting string value, blank is it does not exist
func SettingGet(k string) string {
var result sql.NullString
err := sqlf.From("settings").
err := sqlf.From(tenant("settings")).
Select("Value").To(&result).
Where("Key = ?", k).
Limit(1).
QueryAndClose(nil, db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return ""
@@ -25,7 +26,7 @@ func SettingGet(k string) string {
// SettingPut sets a setting string value, inserting if new
func SettingPut(k, v string) error {
_, err := db.Exec("INSERT INTO settings (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?", k, v, v)
_, err := db.Exec(`INSERT INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
@@ -34,42 +35,42 @@ func SettingPut(k, v string) error {
}
// The total deleted message size as an int64 value
func getDeletedSize() int64 {
var result sql.NullInt64
err := sqlf.From("settings").
func getDeletedSize() float64 {
var result sql.NullFloat64
err := sqlf.From(tenant("settings")).
Select("Value").To(&result).
Where("Key = ?", "DeletedSize").
Limit(1).
QueryAndClose(nil, db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0
}
return result.Int64
return result.Float64
}
// The total raw non-compressed messages size in bytes of all messages in the database
func totalMessagesSize() int64 {
var result sql.NullInt64
err := sqlf.From("mailbox").
func totalMessagesSize() float64 {
var result sql.NullFloat64
err := sqlf.From(tenant("mailbox")).
Select("SUM(Size)").To(&result).
QueryAndClose(nil, db, func(row *sql.Rows) {})
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0
}
return result.Int64
return result.Float64
}
// AddDeletedSize will add the value to the DeletedSize setting
func addDeletedSize(v int64) {
if _, err := db.Exec("INSERT OR IGNORE INTO settings (Key, Value) VALUES(?, ?)", "DeletedSize", 0); err != nil {
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
if _, err := db.Exec("UPDATE settings SET Value = Value + ? WHERE Key = ?", v, "DeletedSize"); err != nil {
if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}

View File

@@ -41,7 +41,7 @@ type Message struct {
// Message body HTML
HTML string
// Message size in bytes
Size int
Size float64
// Inline message attachments
Inline []Attachment
// Message attachments
@@ -61,7 +61,7 @@ type Attachment struct {
// Content ID
ContentID string
// Size in bytes
Size int
Size float64
}
// MessageSummary struct for frontend messages
@@ -91,7 +91,7 @@ type MessageSummary struct {
// Message tags
Tags []string
// Message size in bytes (total)
Size int
Size float64
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
@@ -100,8 +100,8 @@ type MessageSummary struct {
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
Total float64
Unread float64
Tags []string
}
@@ -124,7 +124,7 @@ func AttachmentSummary(a *enmime.Part) Attachment {
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
o.Size = float64(len(a.Content))
return o
}

View File

@@ -0,0 +1,84 @@
package storage
import (
"context"
"database/sql"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)
// TagFilter struct
type TagFilter struct {
Match string
SQL *sqlf.Stmt
Tags []string
}
var tagFilters = []TagFilter{}
// LoadTagFilters loads tag filters from the config and pre-generates the SQL query
func LoadTagFilters() {
tagFilters = []TagFilter{}
for _, t := range config.TagFilters {
match := strings.TrimSpace(t.Match)
if match == "" {
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
continue
}
if t.Tags == nil || len(t.Tags) == 0 {
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
continue
}
validTags := []string{}
for _, tag := range t.Tags {
tagName := tools.CleanTag(tag)
if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {
logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName)
continue
}
validTags = append(validTags, tagName)
}
if len(validTags) == 0 {
continue
}
tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")})
}
}
// TagFilterMatches returns a slice of matching tags from a message
func tagFilterMatches(id string) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
for _, f := range tagFilters {
var matchID string
q := f.SQL.Clone().Where("ID = ?", id)
if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {
var ignore sql.NullString
if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return tags
}
if matchID == id {
tags = append(tags, f.Tags...)
}
}
return tags
}

View File

@@ -1,10 +1,14 @@
package storage
import (
"bytes"
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
@@ -14,117 +18,111 @@ import (
var (
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
addTagMutex sync.RWMutex
)
// SetMessageTags will set the tags for a given database ID
func SetMessageTags(id string, tags []string) error {
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) ([]string, error) {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
for _, t := range applyTags {
t = tools.CleanTag(t)
if t == "" || !config.ValidTagRegexp.MatchString(t) || inArray(t, currentTags) {
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
continue
}
if err := AddMessageTag(id, t); err != nil {
return err
name, err := AddMessageTag(id, t)
if err != nil {
return []string{}, err
}
tagNames = append(tagNames, name)
}
if origTagCount > 0 {
currentTags = getMessageTags(id)
for _, t := range currentTags {
if !inArray(t, applyTags) {
if !tools.InArray(t, applyTags) {
if err := DeleteMessageTag(id, t); err != nil {
return err
return []string{}, err
}
}
}
}
return nil
return tagNames, nil
}
// AddMessageTag adds a tag to a message
func AddMessageTag(id, name string) error {
var tagID int
func AddMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
q := sqlf.From("tags").
var tagID int
var foundName sql.NullString
q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name)
// tag exists - add tag to message
if err := q.QueryRowAndClose(nil, db); err == nil {
// if tag exists - add tag to message
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
addTagMutex.Unlock()
// check message does not already have this tag
var count int
if _, err := sqlf.From("message_tags").
Select("COUNT(ID)").To(&count).
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
ExecAndClose(nil, db); err != nil {
return err
QueryRowAndClose(context.Background(), db); err != nil {
return "", err
}
if count != 0 {
if exists > 0 {
// already exists
return nil
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto("message_tags").
_, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(nil, db)
return err
ExecAndClose(context.TODO(), db)
return foundName.String, err
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
// tag dos not exist, add new one
if err := sqlf.InsertInto("tags").
// new tag, add to the database
if _, err := sqlf.InsertInto(tenant("tags")).
Set("Name", name).
Returning("ID").To(&tagID).
QueryRowAndClose(nil, db); err != nil {
return err
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
return name, err
}
// check message does not already have this tag
var count int
if _, err := sqlf.From("message_tags").
Select("COUNT(ID)").To(&count).
Where("ID = ?", id).
Where("TagID = ?", tagID).
ExecAndClose(nil, db); err != nil {
return err
}
if count != 0 {
return nil // already exists
}
addTagMutex.Unlock()
// add tag to message
_, err := sqlf.InsertInto("message_tags").
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(nil, db)
return err
// add tag to the message
return AddMessageTag(id, name)
}
// DeleteMessageTag deleted a tag from a message
func DeleteMessageTag(id, name string) error {
if _, err := sqlf.DeleteFrom("message_tags").
Where("message_tags.ID = ?", id).
Where(`message_tags.Key IN (SELECT Key FROM message_tags LEFT JOIN tags ON TagID=tags.ID WHERE Name = ?)`, name).
ExecAndClose(nil, db); err != nil {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
@@ -133,9 +131,9 @@ func DeleteMessageTag(id, name string) error {
// DeleteAllMessageTags deleted all tags from a message
func DeleteAllMessageTags(id string) error {
if _, err := sqlf.DeleteFrom("message_tags").
Where("message_tags.ID = ?", id).
ExecAndClose(nil, db); err != nil {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
@@ -149,9 +147,9 @@ func GetAllTags() []string {
if err := sqlf.
Select(`DISTINCT Name`).
From("tags").To(&name).
From(tenant("tags")).To(&name).
OrderBy("Name").
QueryAndClose(nil, db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
@@ -168,12 +166,12 @@ func GetAllTagsCount() map[string]int64 {
if err := sqlf.
Select(`Name`).To(&name).
Select(`COUNT(message_tags.TagID) as total`).To(&total).
From("tags").
LeftJoin("message_tags", "tags.ID = message_tags.TagID").
GroupBy("message_tags.TagID").
Select(`COUNT(`+tenant("message_tags.TagID")+`) as total`).To(&total).
From(tenant("tags")).
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy(tenant("message_tags.TagID")).
OrderBy("Name").
QueryAndClose(nil, db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags[name] = total
// tags = append(tags, name)
}); err != nil {
@@ -183,16 +181,89 @@ func GetAllTagsCount() map[string]int64 {
return tags
}
// RenameTag renames a tag
func RenameTag(from, to string) error {
to = tools.CleanTag(to)
if to == "" || !config.ValidTagRegexp.MatchString(to) {
return fmt.Errorf("invalid tag name: %s", to)
}
if from == to {
return nil // ignore
}
var id, existsID int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, from).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", from)
}
// check if another tag by this name already exists
q = sqlf.From(tenant("tags")).
Select("ID").To(&existsID).
Where(`Name = ?`, to).
Where(`ID != ?`, id).
Limit(1)
err = q.QueryRowAndClose(context.Background(), db)
if err == nil || existsID != 0 {
return fmt.Errorf("tag already exists: %s", to)
}
q = sqlf.Update(tenant("tags")).
Set("Name", to).
Where("ID = ?", id)
_, err = q.ExecAndClose(context.Background(), db)
return err
}
// DeleteTag deleted a tag and removed all references to the tag
func DeleteTag(tag string) error {
var id int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, tag).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", tag)
}
// delete all references
q = sqlf.DeleteFrom(tenant("message_tags")).
Where(`TagID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag references: %s", err.Error())
}
// delete tag
q = sqlf.DeleteFrom(tenant("tags")).
Where(`ID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag: %s", err.Error())
}
return nil
}
// PruneUnusedTags will delete all unused tags from the database
func pruneUnusedTags() error {
q := sqlf.From("tags").
Select("tags.ID, tags.Name, COUNT(message_tags.ID) as COUNT").
LeftJoin("message_tags", "tags.ID = message_tags.TagID").
GroupBy("tags.ID")
q := sqlf.From(tenant("tags")).
Select(tenant("tags.ID")+", "+tenant("tags.Name")+", COUNT("+tenant("message_tags.ID")+") as COUNT").
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy(tenant("tags.ID"))
toDel := []int{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var n string
var id int
var c int
@@ -212,9 +283,9 @@ func pruneUnusedTags() error {
if len(toDel) > 0 {
for _, id := range toDel {
if _, err := sqlf.DeleteFrom("tags").
if _, err := sqlf.DeleteFrom(tenant("tags")).
Where("ID = ?", id).
ExecAndClose(nil, db); err != nil {
ExecAndClose(context.TODO(), db); err != nil {
return err
}
}
@@ -223,51 +294,53 @@ func pruneUnusedTags() error {
return nil
}
// Find tags set via --tags in raw message.
// Find tags set via --tags in raw message, useful for matching all headers etc.
// This function is largely superseded by the database searching, however this
// includes literally everything and is kept for backwards compatibility.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 {
return tagStr
func findTagsInRawMessage(message *[]byte) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
str := bytes.ToLower(*message)
for _, t := range tagFilters {
if bytes.Contains(str, []byte(t.Match)) {
tags = append(tags, t.Tags...)
}
}
return tagStr
return tags
}
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
func (d DBMailSummary) tagsFromPlusAddresses() string {
func (d DBMailSummary) tagsFromPlusAddresses() []string {
tags := []string{}
for _, c := range d.To {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Cc {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Bcc {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
matches := addressPlusRe.FindAllStringSubmatch(d.From.String(), 1)
matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
return strings.Join(tags, ",")
return tools.SetTagCasing(tags)
}
// Get message tags from the database for a given database ID
@@ -278,11 +351,11 @@ func getMessageTags(id string) []string {
if err := sqlf.
Select(`Name`).To(&name).
From("Tags").
LeftJoin("message_tags", "Tags.ID=message_tags.TagID").
Where(`message_tags.ID = ?`, id).
From(tenant("Tags")).
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
Where(tenant("message_tags.ID")+` = ?`, id).
OrderBy("Name").
QueryAndClose(nil, db, func(row *sql.Rows) {
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
@@ -292,24 +365,27 @@ func getMessageTags(id string) []string {
return tags
}
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
func uniqueTagsFromString(s string) []string {
// SortedUniqueTags will return a unique slice of normalised tags
func sortedUniqueTags(s []string) []string {
tags := []string{}
added := make(map[string]bool)
if s == "" {
if len(s) == 0 {
return tags
}
parts := strings.Split(s, ",")
for _, p := range parts {
for _, p := range s {
w := tools.CleanTag(p)
if w == "" {
continue
}
lc := strings.ToLower(w)
if _, exists := added[lc]; exists {
continue
}
if config.ValidTagRegexp.MatchString(w) {
if !inArray(w, tags) {
tags = append(tags, w)
}
added[lc] = true
tags = append(tags, w)
} else {
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
}

View File

@@ -24,7 +24,7 @@ func TestTags(t *testing.T) {
}
for i := 0; i < 10; i++ {
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -58,7 +58,7 @@ func TestTags(t *testing.T) {
// pad number with 0 to ensure they are returned alphabetically
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
}
if err := SetMessageTags(id, newTags); err != nil {
if _, err := SetMessageTags(id, newTags); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -82,7 +82,7 @@ func TestTags(t *testing.T) {
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
// apply the same tag twice
if err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -94,7 +94,7 @@ func TestTags(t *testing.T) {
}
// apply tag with invalid characters
if err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
t.Log("error ", err)
t.Fail()
}

View File

@@ -19,7 +19,7 @@ var (
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
config.Database = os.Getenv("MP_DATABASE")
if err := InitDB(); err != nil {
panic(err)
@@ -27,6 +27,11 @@ func setup() {
var err error
// ensure DB is empty
if err := DeleteAllMessages(); err != nil {
panic(err)
}
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
if err != nil {
panic(err)
@@ -53,11 +58,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
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 float64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
}
if unread != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
if float64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
}
}

View File

@@ -15,7 +15,7 @@ var (
// for stats to prevent import cycle
mu sync.RWMutex
// StatsDeleted for counting the number of messages deleted
StatsDeleted int
StatsDeleted float64
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
@@ -73,7 +73,7 @@ func cleanString(str string) string {
// LogMessagesDeleted logs the number of messages deleted
func logMessagesDeleted(n int) {
mu.Lock()
StatsDeleted = StatsDeleted + n
StatsDeleted = StatsDeleted + float64(n)
mu.Unlock()
}
@@ -87,58 +87,7 @@ func isFile(path string) bool {
return true
}
// Tests if a string is 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
}
// Convert `%` to `%%` 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

@@ -19,18 +19,29 @@ var (
TagsTitleCase bool
)
// CleanTag returns a clean tag, removing whitespace and invalid characters
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
func CleanTag(s string) string {
s = strings.TrimSpace(
return strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
}
if TagsTitleCase {
return cases.Title(language.Und, cases.NoLower).String(s)
// SetTagCasing returns the slice of tags, title-casing if set
func SetTagCasing(s []string) []string {
if !TagsTitleCase {
return s
}
return s
titleTags := []string{}
c := cases.Title(language.Und, cases.NoLower)
for _, t := range s {
titleTags = append(titleTags, c.String(t))
}
return titleTags
}

38
internal/tools/utils.go Normal file
View File

@@ -0,0 +1,38 @@
package tools
import (
"fmt"
"regexp"
"strings"
)
// Plural returns a singular or plural of a word together with the total
func Plural(total int, singular, plural string) string {
if total == 1 {
return fmt.Sprintf("%d %s", total, singular)
}
return fmt.Sprintf("%d %s", total, plural)
}
// InArray tests if a string is 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
}
// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces
func Normalize(s string) string {
nlRe := regexp.MustCompile(`\r?\r`)
re := regexp.MustCompile(`\s+`)
s = nlRe.ReplaceAllString(s, " ")
s = re.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}

View File

@@ -98,53 +98,6 @@ func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error)
return inputFilePath, outputFilePath, err
}
// Write path without the prefix in subPath to tar writer.
func writeTarGz(path string, tarWriter *tar.Writer, fileInfo os.FileInfo, subPath string) error {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("Error closing file: %s\n", err)
}
}()
evaledPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
subPath, err = filepath.EvalSymlinks(subPath)
if err != nil {
return err
}
link := ""
if evaledPath != path {
link = evaledPath
}
header, err := tar.FileInfoHeader(fileInfo, link)
if err != nil {
return err
}
header.Name = evaledPath[len(subPath):]
err = tarWriter.WriteHeader(header)
if err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
if err != nil {
return err
}
return err
}
// Extract the file in filePath to directory.
func extract(filePath string, directory string) error {
file, err := os.Open(filepath.Clean(filePath))
@@ -200,7 +153,7 @@ func extract(filePath string, directory string) error {
// set file ownership (if allowed)
// Chtimes() && Chmod() only set after once extraction is complete
os.Chown(filename, header.Uid, header.Gid) // #nosec
_ = os.Chown(filename, header.Uid, header.Gid)
// add directory info to slice to process afterwards
postExtraction = append(postExtraction, DirInfo{filename, header})
@@ -249,15 +202,15 @@ func extract(filePath string, directory string) error {
}
// set file permissions, timestamps & uid/gid
os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
os.Chtimes(filename, header.AccessTime, header.ModTime) // #nosec
os.Chown(filename, header.Uid, header.Gid) // #nosec
_ = os.Chmod(filename, os.FileMode(header.Mode))
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
_ = os.Chown(filename, header.Uid, header.Gid)
}
if len(postExtraction) > 0 {
for _, dir := range postExtraction {
os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime) // #nosec
os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm()) // #nosec
_ = os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime)
_ = os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm())
}
}

View File

@@ -35,14 +35,14 @@ func Unzip(src string, dest string) ([]string, error) {
if f.FileInfo().IsDir() {
// Make Folder
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}
continue
}
// Make File
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}

View File

@@ -329,22 +329,12 @@ func getTempDir() string {
// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
if !isDir(path) {
return os.MkdirAll(path, os.ModePerm)
return os.MkdirAll(path, os.ModePerm) // #nosec
}
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 if a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)

1380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,12 @@
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"dayjs": "^1.11.10",
"ical.js": "^2.0.1",
"modern-screenshot": "^4.4.30",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"timezones-list": "^3.0.3",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
@@ -27,7 +29,7 @@
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.20.0",
"esbuild": "^0.23.0",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0"
}

View File

@@ -21,6 +21,7 @@ import (
"net/smtp"
"os"
"os/user"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
@@ -42,15 +43,19 @@ var (
)
func init() {
// ensure only valid characters are used, ie: windows
re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`)
host, err := os.Hostname()
if err != nil {
host = "localhost"
} else {
host = re.ReplaceAllString(host, "-")
}
username := "nobody"
user, err := user.Current()
if err == nil && user != nil && len(user.Username) > 0 {
username = user.Username
username = re.ReplaceAllString(user.Username, "-")
}
if FromAddr == "" {
@@ -62,7 +67,7 @@ func init() {
func Run() {
var recipients []string
// defaults from envars if provided
// defaults from env vars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}

View File

@@ -9,18 +9,13 @@ import (
"net/mail"
"strconv"
"strings"
"time"
"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/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// GetMessages returns a paginated list of messages as JSON
@@ -67,15 +62,16 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
res.Start = start
res.Messages = messages
res.Count = len(messages) // legacy - now undocumented in API specs
res.Count = float64(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)
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// Search returns the latest messages as JSON
@@ -84,7 +80,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
//
// # Search messages
//
// Returns the latest messages matching a search.
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
//
// Produces:
// - application/json
@@ -109,6 +105,11 @@ func Search(w http.ResponseWriter, r *http.Request) {
// required: false
// type: integer
// default: 50
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: MessagesSummaryResponse
@@ -121,7 +122,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r)
messages, results, err := storage.Search(search, start, limit)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
if err != nil {
httpError(w, err.Error())
return
@@ -133,15 +134,16 @@ func Search(w http.ResponseWriter, r *http.Request) {
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.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = float64(results)
res.Unread = stats.Unread
res.Tags = stats.Tags
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// DeleteSearch will delete all messages matching a search
@@ -150,7 +152,7 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
//
// # Delete messages by search
//
// Delete all messages matching a search.
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
//
// Produces:
// - application/json
@@ -163,6 +165,11 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// description: Search query
// required: true
// type: string
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: OKResponse
@@ -173,7 +180,7 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
return
}
if err := storage.DeleteSearch(search); err != nil {
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
httpError(w, err.Error())
return
}
@@ -228,9 +235,10 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(msg); err != nil {
httpError(w, err.Error())
}
}
// DownloadAttachment (method: GET) returns the attachment data
@@ -337,10 +345,10 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(m.Header)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
httpError(w, err.Error())
}
}
// DownloadRaw (method: GET) returns the full email source as plain text
@@ -428,11 +436,9 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
return
}
} else {
for _, id := range data.IDs {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
if err := storage.DeleteMessages(data.IDs); err != nil {
httpError(w, err.Error())
return
}
}
@@ -512,219 +518,14 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # 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)
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// 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.SetMessageTags(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.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// 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 := releaseMessageRequestBody{}
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.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.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"})
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
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if 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 HTMLCheck
//
// # HTML check (beta)
// # HTML check
//
// Returns the summary of the message HTML checker.
//
// NOTE: This feature is currently in beta and is documented for reference only.
// Please do not integrate with it (yet) as there may be changes.
//
// Produces:
// - application/json
//
@@ -764,22 +565,20 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(checks)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(checks); err != nil {
httpError(w, err.Error())
}
}
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
//
// # Link check (beta)
// # Link check
//
// 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
//
@@ -817,21 +616,19 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck
//
// # SpamAssassin check (beta)
// # SpamAssassin check
//
// Returns the SpamAssassin (if enabled) summary of the message.
//
// 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.
// Returns the SpamAssassin summary (if enabled) of the message.
//
// Produces:
// - application/json
@@ -867,9 +664,10 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// FourOFour returns a basic 404 message
@@ -890,6 +688,21 @@ func httpError(w http.ResponseWriter, msg string) {
fmt.Fprint(w, msg)
}
// httpJSONError returns a basic error message (400 response) in JSON format
func httpJSONError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
e := JSONErrorMessage{
Error: msg,
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(e); err != nil {
httpError(w, err.Error())
}
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0

View File

@@ -24,10 +24,8 @@ func AppInfo(w http.ResponseWriter, _ *http.Request) {
// 200: InfoResponse
// default: ErrorResponse
info := stats.Load()
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
httpError(w, err.Error())
}
}

167
server/apiv1/release.go Normal file
View File

@@ -0,0 +1,167 @@
package apiv1
import (
"bytes"
"encoding/json"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// 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 := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
blocked := []string{}
notAllowed := []string{}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
notAllowed = append(notAllowed, to)
continue
}
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) {
blocked = append(blocked, to)
continue
}
}
if len(notAllowed) > 0 {
addr := tools.Plural(len(notAllowed), "Address", "Addresses")
httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", "))
return
}
if len(blocked) > 0 {
addr := tools.Plural(len(blocked), "Address", "Addresses")
httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", "))
return
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[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"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP from
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
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Send(from, data.To, 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"))
}

275
server/apiv1/send.go Normal file
View File

@@ -0,0 +1,275 @@
package apiv1
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"strings"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/jhillyerd/enmime"
)
// swagger:parameters SendMessage
type sendMessageParams struct {
// in: body
Body *SendRequest
}
// SendRequest to send a message via HTTP
// swagger:model SendRequest
type SendRequest struct {
// "From" recipient
// required: true
From struct {
// Optional name
// example: John Doe
Name string
// Email address
// example: john@example.com
// required: true
Email string
}
// "To" recipients
To []struct {
// Optional name
// example: Jane Doe
Name string
// Email address
// example: jane@example.com
// required: true
Email string
}
// Cc recipients
Cc []struct {
// Optional name
// example: Manager
Name string
// Email address
// example: manager@example.com
// required: true
Email string
}
// Bcc recipients email addresses only
// example: ["jack@example.com"]
Bcc []string
// Optional Reply-To recipients
ReplyTo []struct {
// Optional name
// example: Secretary
Name string
// Email address
// example: secretary@example.com
// required: true
Email string
}
// Subject
// example: Mailpit message via the HTTP API
Subject string
// Message body (text)
// example: This is the text body
Text string
// Message body (HTML)
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
HTML string
// Attachments
Attachments []struct {
// Base64-encoded string of the file content
// required: true
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
Content string
// Filename
// required: true
// example: AttachedFile.txt
Filename string
}
// Mailpit tags
// example: ["Tag 1","Tag 2"]
Tags []string
// Optional headers in {"key":"value"} format
// example: {"X-IP":"1.2.3.4"}
Headers map[string]string
}
// SendMessageConfirmation struct
type SendMessageConfirmation struct {
// Database ID
// example: iAfZVVe2UQFNSG5BAjgYwa
ID string
}
// JSONErrorMessage struct
type JSONErrorMessage struct {
// Error message
// example: invalid format
Error string
}
// SendMessageHandler handles HTTP requests to send a new message
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/send message SendMessage
//
// # Send a message
//
// Send a message via the HTTP API.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: sendMessageResponse
// default: jsonErrorResponse
decoder := json.NewDecoder(r.Body)
data := SendRequest{}
if err := decoder.Decode(&data); err != nil {
httpJSONError(w, err.Error())
return
}
id, err := data.Send(r.RemoteAddr)
if err != nil {
httpJSONError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
httpError(w, err.Error())
}
}
// Send will validate the message structure and attempt to send to Mailpit.
// It returns a sending summary or an error.
func (d SendRequest) Send(remoteAddr string) (string, error) {
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
}
ipAddr := &net.IPAddr{IP: net.ParseIP(ip)}
addresses := []string{}
msg := enmime.Builder().
From(d.From.Name, d.From.Email).
Subject(d.Subject).
Text([]byte(d.Text))
if d.HTML != "" {
msg = msg.HTML([]byte(d.HTML))
}
if len(d.To) > 0 {
for _, a := range d.To {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.To(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid To address: %s", a.Email)
}
}
}
if len(d.Cc) > 0 {
for _, a := range d.Cc {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.CC(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid Cc address: %s", a.Email)
}
}
}
if len(d.Bcc) > 0 {
for _, e := range d.Bcc {
if _, err := mail.ParseAddress(e); err == nil {
msg = msg.BCC("", e)
addresses = append(addresses, e)
} else {
return "", fmt.Errorf("invalid Bcc address: %s", e)
}
}
}
if len(d.ReplyTo) > 0 {
for _, a := range d.ReplyTo {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.ReplyTo(a.Name, a.Email)
} else {
return "", fmt.Errorf("invalid Reply-To address: %s", a.Email)
}
}
}
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
if len(d.Tags) > 0 {
msg = msg.Header("X-Tags", strings.Join(d.Tags, ", "))
restrictedHeaders = append(restrictedHeaders, "X-Tags")
}
if len(d.Headers) > 0 {
for k, v := range d.Headers {
// check header isn't in "restricted" headers
if tools.InArray(k, restrictedHeaders) {
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
}
msg = msg.Header(k, v)
}
}
if len(d.Attachments) > 0 {
for _, a := range d.Attachments {
// workaround: split string because JS readAsDataURL() returns the base64 string
// with the mime type prefix eg: data:image/png;base64,<base64String>
parts := strings.Split(a.Content, ",")
content := parts[len(parts)-1]
b, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
}
mimeType := http.DetectContentType(b)
msg = msg.AddAttachment(b, mimeType, a.Filename)
}
}
part, err := msg.Build()
if err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
var buff bytes.Buffer
if err := part.Encode(io.Writer(&buff)); err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
}

View File

@@ -10,18 +10,18 @@ import (
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total int `json:"total"`
Total float64 `json:"total"`
// Total number of unread messages in mailbox
Unread int `json:"unread"`
Unread float64 `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"`
Count float64 `json:"count"`
// Total number of messages matching current query
MessagesCount int `json:"messages_count"`
MessagesCount float64 `json:"messages_count"`
// Pagination offset
Start int `json:"start"`

View File

@@ -47,7 +47,7 @@ type deleteMessagesRequestBody struct {
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters SetReadStatus
@@ -64,13 +64,13 @@ type setReadStatusRequestBody struct {
// required: false
// default: false
// example: true
Read bool `json:"read"`
Read bool
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters SetTags
@@ -86,13 +86,29 @@ type setTagsRequestBody struct {
//
// required: true
// example: ["Tag 1", "Tag 2"]
Tags []string `json:"tags"`
Tags []string
// Array of message database IDs
//
// required: true
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters RenameTag
type renameTagParams struct {
// in: body
Body *renameTagRequestBody
}
// Rename tag request
// swagger:model renameTagRequestBody
type renameTagRequestBody struct {
// New name
//
// required: true
// example: New name
Name string
}
// swagger:parameters ReleaseMessage
@@ -112,10 +128,9 @@ type releaseMessageParams struct {
// swagger:model releaseMessageRequestBody
type releaseMessageRequestBody struct {
// Array of email addresses to relay the message to
//
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string `json:"to"`
To []string
}
// swagger:parameters HTMLCheck
@@ -156,7 +171,7 @@ type spamAssassinCheckParams struct {
ID string
}
// Binary data response inherits the attachment's content type
// Binary data response inherits the attachment's content type.
// swagger:response BinaryResponse
type binaryResponse string
@@ -170,6 +185,7 @@ type htmlResponse string
// HTTP error response will return with a >= 400 response code
// swagger:response ErrorResponse
// example: invalid request
type errorResponse string
// Plain text "ok" response
@@ -179,3 +195,21 @@ type okResponse string
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string
// Confirmation message for HTTP send API
// swagger:response sendMessageResponse
type sendMessageResponse struct {
// Response for sending messages via the HTTP API
//
// in: body
Body SendMessageConfirmation
}
// JSON error response
// swagger:response jsonErrorResponse
type jsonErrorResponse struct {
// A JSON-encoded error response
//
// in: body
Body JSONErrorMessage
}

171
server/apiv1/tags.go Normal file
View File

@@ -0,0 +1,171 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// 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.SetMessageTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// RenameTag (method: PUT) used to rename a tag
func RenameTag(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags/{tag} tags RenameTag
//
// # Rename a tag
//
// Renames a tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to rename
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
decoder := json.NewDecoder(r.Body)
var data struct {
Name string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
if err := storage.RenameTag(tag, data.Name); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// DeleteTag (method: DELETE) used to delete a tag
func DeleteTag(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag
//
// # Delete a tag
//
// Deletes a tag. This will not delete any messages with this tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to delete
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
if err := storage.DeleteTag(tag); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -12,9 +12,9 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
"github.com/kovidgoyal/imaging"
)
var (
@@ -74,7 +74,7 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf)
img, err := imaging.Decode(buf, imaging.AutoOrientation(true))
if err != nil {
// it's not an image, return default
logger.Log().Warnf("[image] %s", err.Error())
@@ -114,7 +114,7 @@ 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)
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src)
var b bytes.Buffer
foo := bufio.NewWriter(&b)
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)

View File

@@ -12,6 +12,8 @@ import (
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
@@ -22,14 +24,13 @@ type webUIConfiguration struct {
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
}
// Whether the HTML check has been globally disabled
DisableHTMLCheck bool
// Whether SpamAssassin is enabled
SpamAssassin bool
@@ -56,21 +57,22 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// default: ErrorResponse
conf := webUIConfiguration{}
conf.Label = config.Label
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.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
// DEPRECATED 2024/03/12
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
}
conf.DisableHTMLCheck = config.DisableHTMLCheck
conf.SpamAssassin = config.EnableSpamAssassin != ""
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
bytes, _ := json.Marshal(conf)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(conf); err != nil {
httpError(w, err.Error())
}
}

View File

@@ -3,12 +3,14 @@ package handlers
import (
"net/http"
"sync/atomic"
"github.com/axllent/mailpit/internal/storage"
)
// 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) {
if isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}

View File

@@ -19,7 +19,7 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = storage.Search(search, 0, 1)
messages, _, err = storage.Search(search, "", 0, 1)
if err != nil {
httpError(w, err.Error())
return

View File

@@ -72,5 +72,5 @@ func getSafeArg(args []string, nr int) (string, error) {
if nr < len(args) {
return args[nr], nil
}
return "", errors.New("Out of range")
return "", errors.New("-ERR out of range")
}

View File

@@ -1,314 +0,0 @@
// Package pop3 is a simple POP3 server for Mailpit.
// By default it is disabled unless password credentials have been loaded.
//
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
const (
// UNAUTHORIZED state
UNAUTHORIZED = 1
// TRANSACTION state
TRANSACTION = 2
// UPDATE state
UPDATE = 3
)
// Run will start the pop3 server if enabled
func Run() {
if auth.POP3Credentials == nil || config.POP3Listen == "" {
// POP3 server is disabled without authentication
return
}
var listener net.Listener
var err error
if config.POP3TLSCert != "" {
cer, err := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
}
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
} else {
// unencrypted
listener, err = net.Listen("tcp", config.POP3Listen)
}
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
// run as goroutine
go handleClient(conn)
}
}
type message struct {
ID string
Size int
}
func handleClient(conn net.Conn) {
var (
user = ""
state = 1
toDelete = []string{}
)
defer func() {
if state == UPDATE {
for _, id := range toDelete {
_ = storage.DeleteOneMessage(id)
}
if len(toDelete) > 0 {
// update web UI to remove deleted messages
websockets.Broadcast("prune", nil)
}
}
if err := conn.Close(); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
}()
reader := bufio.NewReader(conn)
messages := []message{}
// State
// 1 = Unauthorized
// 2 = Transaction mode
// 3 = update mode
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
sendResponse(conn, "+OK Mailpit POP3 server")
timeoutDuration := 30 * time.Second
for {
// POP3 server enforced a timeout of 30 seconds
if err := conn.SetDeadline(time.Now().Add(timeoutDuration)); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Reads a line from the client
rawLine, err := reader.ReadString('\n')
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Parses the command
cmd, args := getCommand(rawLine)
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
if cmd == "CAPA" {
// List our capabilities per RFC2449
sendResponse(conn, "+OK Capability list follows")
sendResponse(conn, "TOP")
sendResponse(conn, "USER")
sendResponse(conn, "UIDL")
sendResponse(conn, "IMPLEMENTATION Mailpit")
sendResponse(conn, ".")
continue
} else if cmd == "USER" && state == UNAUTHORIZED {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a user")
return
}
// always true - stash for PASS
sendResponse(conn, "+OK")
user = args[0]
} else if cmd == "PASS" && state == UNAUTHORIZED {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a password")
return
}
pass := args[0]
if authUser(user, pass) {
sendResponse(conn, "+OK signed in")
messages, err = getMessages()
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
state = 2
} else {
sendResponse(conn, "-ERR invalid password")
logger.Log().Warnf("[pop3] failed login: %s", user)
}
} else if cmd == "STAT" && state == TRANSACTION {
totalSize := 0
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize))
} else if cmd == "LIST" && state == TRANSACTION {
totalSize := 0
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize))
// print all sizes
for row, m := range messages {
sendData(conn, fmt.Sprintf("%d %d", row+1, m.Size))
}
// end
sendData(conn, ".")
} else if cmd == "UIDL" && state == TRANSACTION {
totalSize := 0
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendData(conn, "+OK unique-id listing follows")
// print all message IDS
for row, m := range messages {
sendData(conn, fmt.Sprintf("%d %s", row+1, m.ID))
}
// end
sendData(conn, ".")
} else if cmd == "RETR" && state == TRANSACTION {
if len(args) != 1 {
sendResponse(conn, "-ERR no such message")
return
}
nr, err := strconv.Atoi(args[0])
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
if nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
raw, err := storage.GetMessageRaw(m.ID)
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
size := len(raw)
sendData(conn, fmt.Sprintf("+OK %d octets", size))
sendData(conn, string(raw))
sendData(conn, ".")
} else if cmd == "TOP" && state == TRANSACTION {
arg, err := getSafeArg(args, 0)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
nr, err := strconv.Atoi(arg)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
if nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
arg2, err := getSafeArg(args, 1)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
lines, err := strconv.Atoi(arg2)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
m := messages[nr-1]
headers, body, err := getTop(m.ID, lines)
sendData(conn, "+OK Top of message follows")
sendData(conn, headers+"\r\n")
sendData(conn, body)
sendData(conn, ".")
} else if cmd == "NOOP" && state == TRANSACTION {
sendData(conn, "+OK")
} else if cmd == "DELE" && state == TRANSACTION {
arg, _ := getSafeArg(args, 0)
nr, err := strconv.Atoi(arg)
if err != nil {
logger.Log().Warnf("[pop3] -ERR invalid DELETE integer: %s", arg)
sendResponse(conn, "-ERR invalid integer")
return
}
if nr < 1 || nr > len(messages) {
logger.Log().Warnf("[pop3] -ERR no such message")
sendResponse(conn, "-ERR no such message")
return
}
toDelete = append(toDelete, messages[nr-1].ID)
sendResponse(conn, "+OK")
} else if cmd == "RSET" && state == TRANSACTION {
toDelete = []string{}
sendData(conn, "+OK")
} else if cmd == "QUIT" {
state = UPDATE
return
} else {
logger.Log().Warnf("[pop3] -ERR %s not implemented", cmd)
sendResponse(conn, fmt.Sprintf("-ERR %s not implemented", cmd))
}
}
}

365
server/pop3/pop3_test.go Normal file
View File

@@ -0,0 +1,365 @@
package pop3
import (
"bytes"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"testing"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3client"
"github.com/axllent/mailpit/internal/storage"
"github.com/jhillyerd/enmime"
)
var (
testingPort int
)
func TestPOP3(t *testing.T) {
t.Log("Testing POP3 server")
setup()
defer storage.Close()
// connect with bad password
t.Log("Testing invalid login")
c, err := connectBadAuth()
if err == nil {
t.Error("invalid login gained access")
return
}
t.Log("Testing valid login")
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, size, err := c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 0, "incorrect message count")
assertEqual(t, size, 0, "incorrect size")
// quit else we get old data
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Inserting 50 messages")
insertEmailData(t) // insert 50 messages
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 50, "incorrect message count")
t.Log("Fetching 20 messages")
for i := 1; i <= 20; i++ {
_, err := c.Retr(i)
if err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Fetching message count")
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Undeleting messages")
if err := c.Rset(); err != nil {
t.Errorf(err.Error())
return
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func TestAuthentication(t *testing.T) {
// commands only allowed after authentication
authCommands := make(map[string]bool)
authCommands["STAT"] = false
authCommands["LIST"] = true
authCommands["NOOP"] = false
authCommands["RSET"] = false
authCommands["RETR 1"] = true
t.Log("Testing authenticated commands while not logged in")
setup()
defer storage.Close()
insertEmailData(t) // insert 50 messages
// non-authenticated connection
c, err := connect()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Testing authenticated commands while logged in")
// authenticated connection
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func setup() {
auth.SetPOP3Auth("username:password")
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
var foundPort bool
for !foundPort {
testingPort = randRange(1111, 2000)
if portFree(testingPort) {
foundPort = true
}
}
config.POP3Listen = fmt.Sprintf("localhost:%d", testingPort)
if err := storage.InitDB(); err != nil {
panic(err)
}
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
go Run()
time.Sleep(time.Second)
}
// connect and authenticate
func connectAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "password")
return c, err
}
// connect and authenticate
func connectBadAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "notPassword")
return c, err
}
// connect but do not authenticate
func connect() (*pop3client.Conn, error) {
p := pop3client.New(pop3client.Opt{
Host: "localhost",
Port: testingPort,
TLSEnabled: false,
})
c, err := p.NewConn()
if err != nil {
return c, err
}
return c, err
}
func portFree(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return false
}
if err := ln.Close(); err != nil {
panic(err)
}
return true
}
func randRange(min, max int) int {
return rand.IntN(max-min) + min
}
func insertEmailData(t *testing.T) {
for i := 0; i < 50; 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()
}
bufBytes := buf.Bytes()
id, err := storage.Store(&bufBytes)
if err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
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)
}

319
server/pop3/server.go Normal file
View File

@@ -0,0 +1,319 @@
// Package pop3 is a simple POP3 server for Mailpit.
// By default it is disabled unless password credentials have been loaded.
//
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
const (
// AUTHORIZATION is the initial state
AUTHORIZATION = 1
// TRANSACTION is the state after login
TRANSACTION = 2
// UPDATE is the state before closing
UPDATE = 3
)
// Run will start the POP3 server if enabled
func Run() {
if auth.POP3Credentials == nil || config.POP3Listen == "" {
// POP3 server is disabled without authentication
return
}
var listener net.Listener
var err error
if config.POP3TLSCert != "" {
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
if err2 != nil {
logger.Log().Errorf("[pop3] %s", err2.Error())
return
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
}
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
} else {
// unencrypted
listener, err = net.Listen("tcp", config.POP3Listen)
}
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
for {
conn, err := listener.Accept()
if err != nil {
logger.Log().Errorf("[pop3] accept error: %s", err.Error())
continue
}
// run as goroutine
go handleClient(conn)
}
}
type message struct {
ID string
Size float64
}
func handleClient(conn net.Conn) {
var (
user = ""
state = AUTHORIZATION // Start with AUTHORIZATION state
toDelete []string // Track messages marked for deletion
messages []message
)
defer func() {
if state == UPDATE {
if len(toDelete) > 0 {
if err := storage.DeleteMessages(toDelete); err != nil {
logger.Log().Errorf("[pop3] error deleting: %s", err.Error())
}
// Update web UI to remove deleted messages
websockets.Broadcast("prune", nil)
}
}
if err := conn.Close(); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
}()
reader := bufio.NewReader(conn)
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
serverName := "Mailpit"
if config.Label != "" {
serverName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
sendResponse(conn, fmt.Sprintf("+OK %s POP3 server", serverName))
// Set 10 minutes timeout according to RFC1939
timeoutDuration := 600 * time.Second
for {
// Set read deadline
if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Reads a line from the client
rawLine, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
logger.Log().Debugf("[pop3] client disconnected: %s", conn.RemoteAddr().String())
} else {
logger.Log().Errorf("[pop3] read error: %s", err.Error())
}
return
}
// Parses the command
cmd, args := getCommand(rawLine)
cmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
sendResponse(conn, "+OK capability list follows")
sendResponse(conn, "TOP")
sendResponse(conn, "USER")
sendResponse(conn, "UIDL")
sendResponse(conn, "IMPLEMENTATION Mailpit")
sendResponse(conn, ".")
case "USER":
if state == AUTHORIZATION {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a user")
return
}
sendResponse(conn, "+OK")
user = args[0]
} else {
sendResponse(conn, "-ERR user already specified")
}
case "PASS":
if state == AUTHORIZATION {
if user == "" {
sendResponse(conn, "-ERR must supply a user")
return
}
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a password")
return
}
pass := args[0]
if authUser(user, pass) {
sendResponse(conn, "+OK signed in")
var err error
messages, err = getMessages()
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
state = TRANSACTION
} else {
sendResponse(conn, "-ERR invalid password")
logger.Log().Warnf("[pop3] failed login: %s", user)
}
} else {
sendResponse(conn, "-ERR user not specified")
}
case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE", "RSET":
if state == TRANSACTION {
handleTransactionCommand(conn, cmd, args, messages, &toDelete)
} else {
sendResponse(conn, "-ERR user not authenticated")
}
case "QUIT":
sendResponse(conn, "+OK goodbye")
state = UPDATE
return
default:
sendResponse(conn, "-ERR unknown command")
}
}
}
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
switch cmd {
case "STAT":
totalSize := float64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
case "LIST":
totalSize := float64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
for row, m := range messages {
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
}
sendResponse(conn, ".")
case "UIDL":
sendResponse(conn, "+OK unique-id listing follows")
for row, m := range messages {
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
}
sendResponse(conn, ".")
case "RETR":
if len(args) != 1 {
sendResponse(conn, "-ERR no such message")
return
}
nr, err := strconv.Atoi(args[0])
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
raw, err := storage.GetMessageRaw(m.ID)
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
size := len(raw)
sendResponse(conn, fmt.Sprintf("+OK %d octets", size))
// When all lines of the response have been sent, a
// final line is sent, consisting of a termination octet (decimal code
// 046, ".") and a CRLF pair. If any line of the multi-line response
// begins with the termination octet, the line is "byte-stuffed" by
// pre-pending the termination octet to that line of the response.
// @see: https://www.ietf.org/rfc/rfc1939.txt
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
sendResponse(conn, ".")
case "TOP":
arg, err := getSafeArg(args, 0)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
arg2, err := getSafeArg(args, 1)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
lines, err := strconv.Atoi(arg2)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
m := messages[nr-1]
headers, body, err := getTop(m.ID, lines)
if err != nil {
sendResponse(conn, err.Error())
return
}
sendResponse(conn, "+OK top of message follows")
sendData(conn, headers+"\r\n")
sendData(conn, body)
sendResponse(conn, ".")
case "NOOP":
sendResponse(conn, "+OK")
case "DELE":
arg, _ := getSafeArg(args, 0)
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
*toDelete = append(*toDelete, m.ID)
sendResponse(conn, "+OK message marked for deletion")
case "RSET":
*toDelete = []string{}
sendResponse(conn, "+OK")
default:
sendResponse(conn, "-ERR unknown command")
}
}

View File

@@ -96,8 +96,6 @@ func Listen() {
// Mark the application here as ready
isReady.Store(true)
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
server := &http.Server{
Addr: config.HTTPListen,
ReadTimeout: 30 * time.Second,
@@ -105,11 +103,20 @@ func Listen() {
}
if config.UITLSCert != "" && config.UITLSKey != "" {
logger.Log().Infof("[http] starting on %s (TLS)", config.HTTPListen)
logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(server.ListenAndServeTLS(config.UITLSCert, config.UITLSKey))
if err := server.ListenAndServeTLS(config.UITLSCert, config.UITLSKey); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
} else {
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(server.ListenAndServe())
if err := server.ListenAndServe(); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
}
}
@@ -120,18 +127,19 @@ func apiRoutes() *mux.Router {
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.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).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/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).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}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
if config.EnableSpamAssassin != "" {
r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).Methods("GET")
@@ -193,20 +201,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
}
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r)
return
@@ -332,8 +326,6 @@ func index(w http.ResponseWriter, _ *http.Request) {
panic(err)
}
buff.Bytes()
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(buff.Bytes())
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
@@ -20,8 +21,8 @@ import (
var (
putDataStruct struct {
Read bool `json:"read"`
IDs []string `json:"ids"`
Read bool
IDs []string
}
)
@@ -47,8 +48,6 @@ func TestAPIv1Messages(t *testing.T) {
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())
@@ -56,7 +55,6 @@ func TestAPIv1Messages(t *testing.T) {
// 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
@@ -71,13 +69,10 @@ func TestAPIv1Messages(t *testing.T) {
t.Errorf(err.Error())
}
// het headers
// get 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
@@ -207,14 +202,118 @@ func TestAPIv1Search(t *testing.T) {
assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99)
}
func TestAPIv1Send(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
jsonData := `{
"From": {
"Email": "john@example.com",
"Name": "John Doe"
},
"To": [
{
"Email": "jane@example.com",
"Name": "Jane Doe"
}
],
"Cc": [
{
"Email": "manager1@example.com",
"Name": "Manager 1"
},
{
"Email": "manager2@example.com",
"Name": "Manager 2"
}
],
"Bcc": ["jack@example.com"],
"Headers": {
"X-IP": "1.2.3.4"
},
"Subject": "Mailpit message via the HTTP API",
"Text": "This is the text body",
"HTML": "<p style=\"font-family: arial\">Mailpit is <b>awesome</b>!</p>",
"Attachments": [
{
"Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==",
"Filename": "Attached File.txt"
}
],
"ReplyTo": [
{
"Email": "secretary@example.com",
"Name": "Secretary"
}
],
"Tags": [
"Tag 1",
"Tag 2"
]
}`
t.Log("Sending message via HTTP API")
b, err := clientPost(ts.URL+"/api/v1/send", jsonData)
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
resp := apiv1.SendMessageConfirmation{}
if err := json.Unmarshal(b, &resp); err != nil {
t.Errorf(err.Error())
return
}
t.Logf("Fetching response for message %s", resp.ID)
msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID)
if err != nil {
t.Errorf(err.Error())
}
t.Logf("Testing response for message %s", resp.ID)
assertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, "wrong subject")
assertEqual(t, `This is the text body`, msg.Text, "wrong text")
assertEqual(t, `<p style="font-family: arial">Mailpit is <b>awesome</b>!</p>`, msg.HTML, "wrong HTML")
assertEqual(t, `"John Doe" <john@example.com>`, msg.From.String(), "wrong HTML")
assertEqual(t, 1, len(msg.To), "wrong To count")
assertEqual(t, `"Jane Doe" <jane@example.com>`, msg.To[0].String(), "wrong To address")
assertEqual(t, 2, len(msg.Cc), "wrong Cc count")
assertEqual(t, `"Manager 1" <manager1@example.com>`, msg.Cc[0].String(), "wrong Cc address")
assertEqual(t, `"Manager 2" <manager2@example.com>`, msg.Cc[1].String(), "wrong Cc address")
assertEqual(t, 1, len(msg.Bcc), "wrong Bcc count")
assertEqual(t, `<jack@example.com>`, msg.Bcc[0].String(), "wrong Bcc address")
assertEqual(t, 1, len(msg.ReplyTo), "wrong Reply-To count")
assertEqual(t, `"Secretary" <secretary@example.com>`, msg.ReplyTo[0].String(), "wrong Reply-To address")
assertEqual(t, 2, len(msg.Tags), "wrong Tags count")
assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags")
assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count")
assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name")
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
if err != nil {
t.Errorf(err.Error())
}
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
config.Database = os.Getenv("MP_DATABASE")
if err := storage.InitDB(); err != nil {
panic(err)
}
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
}
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
@@ -231,8 +330,8 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
return
}
assertEqual(t, unread, m.Unread, "wrong unread count")
assertEqual(t, total, m.Total, "wrong total count")
assertEqual(t, float64(unread), m.Unread, "wrong unread count")
assertEqual(t, float64(total), m.Total, "wrong total count")
}
func assertSearchEqual(t *testing.T, uri, query string, count int) {
@@ -252,7 +351,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
return
}
assertEqual(t, count, m.MessagesCount, "wrong search results count")
assertEqual(t, float64(count), m.MessagesCount, "wrong search results count")
}
func insertEmailData(t *testing.T) {
@@ -284,12 +383,26 @@ func insertEmailData(t *testing.T) {
t.Fail()
}
if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func fetchMessage(url string) (storage.Message, error) {
m := storage.Message{}
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 fetchMessages(url string) (apiv1.MessagesSummary, error) {
@@ -373,6 +486,31 @@ func clientPut(url, body string) ([]byte, error) {
return data, err
}
func clientPost(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("POST", 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

54
server/smtpd/relay.go Normal file
View File

@@ -0,0 +1,54 @@
package smtpd
import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
func autoRelayMessage(from string, to []string, data *[]byte) {
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
filteredTo := []string{}
for _, address := range to {
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address)
continue
}
filteredTo = append(filteredTo, address)
}
to = filteredTo
}
if len(to) == 0 {
return
}
if config.SMTPRelayAll {
if err := Send(from, to, *data); err != nil {
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
} else {
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
}
} else if config.SMTPRelayMatchingRegexp != nil {
filtered := []string{}
for _, t := range to {
if config.SMTPRelayMatchingRegexp.MatchString(t) {
filtered = append(filtered, t)
}
}
if len(filtered) == 0 {
return
}
if err := Send(from, filtered, *data); err != nil {
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
} else {
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
}
}
}

View File

@@ -4,46 +4,14 @@ 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.AllowedRecipientsRegexp == 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.AllowedRecipientsRegexp.MatchString(address.Address) {
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.AllowedRecipients)
} 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)
@@ -75,7 +43,7 @@ func Send(from string, to []string, msg []byte) error {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
for _, addr := range recipients {
for _, addr := range to {
if err = c.Rcpt(addr); err != nil {
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
}

View File

@@ -23,7 +23,13 @@ var (
DisableReverseDNS bool
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
// MailHandler handles the incoming message to store in the database
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
return Store(origin, from, to, data)
}
// Store will attempt to save a message to the database
func Store(origin net.Addr, from string, to []string, data []byte) (string, 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
@@ -34,7 +40,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
stats.LogSMTPRejected()
return err
return "", err
}
// check / set the Return-Path based on SMTP from
@@ -70,18 +76,12 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
stats.LogSMTPIgnored()
return nil
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)
}
}
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
autoRelayMessage(from, to, &data)
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
@@ -123,10 +123,10 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
_, err = storage.Store(&data)
id, err := storage.Store(&data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
return "", err
}
stats.LogSMTPAccepted(len(data))
@@ -136,7 +136,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return nil
return id, err
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
@@ -210,10 +210,10 @@ func Listen() error {
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
MsgIDHandler: handler,
HandlerRcpt: handlerRcpt,
Appname: "Mailpit",
Hostname: "",
@@ -223,6 +223,10 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
DisableReverseDNS: DisableReverseDNS,
}
if config.Label != "" {
srv.Appname = fmt.Sprintf("Mailpit (%s)", config.Label)
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}

View File

@@ -2,6 +2,7 @@
import CommonMixins from './mixins/CommonMixins'
import Favicon from './components/Favicon.vue'
import Notifications from './components/Notifications.vue'
import EditTags from './components/EditTags.vue'
import { RouterView } from 'vue-router'
import { mailbox } from "./stores/mailbox"
@@ -11,15 +12,20 @@ export default {
components: {
Favicon,
Notifications,
EditTags
},
beforeMount() {
document.title = document.title + ' - ' + location.hostname
mailbox.showTagColors = !localStorage.getItem('hideTagColors') == '1'
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
if (mailbox.uiConfig.Label) {
document.title = document.title + ' - ' + mailbox.uiConfig.Label
} else {
document.title = document.title + ' - ' + location.hostname
}
})
},
@@ -37,4 +43,5 @@ export default {
<RouterView />
<Favicon />
<Notifications />
<EditTags />
</template>

View File

@@ -320,16 +320,18 @@ body.blur {
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
#message-view {
.form-control.dropdown {
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
div {
cursor: text; // html5-tags
}
}
}
@@ -355,14 +357,6 @@ body.blur {
}
}
#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-"],

View File

@@ -1,5 +1,6 @@
<script>
import AjaxLoader from './AjaxLoader.vue'
import Settings from '../components/Settings.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
@@ -7,7 +8,8 @@ export default {
mixins: [CommonMixins],
components: {
AjaxLoader
AjaxLoader,
Settings,
},
props: {
@@ -20,66 +22,18 @@ export default {
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) {
loadInfo() {
this.get(this.resolve('/api/v1/info'), false, (response) => {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
this.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 () {
requestNotifications() {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notifications")
@@ -87,7 +41,6 @@ export default {
// 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
@@ -101,42 +54,17 @@ export default {
<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">
<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="#SettingsModal" title="Mailpit UI settings">
<i class="bi bi-gear-fill"></i>
</button>
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
@@ -163,15 +91,20 @@ export default {
<div class="row g-3">
<div class="col-xl-6">
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
<div class="col">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
</div>
</div>
</div>
<div class="row g-3" v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
<div class="row g-3"
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
<div class="col">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
</div>
</div>
<div class="row g-3">
<div class="col-12">
@@ -197,7 +130,8 @@ export default {
<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>
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
</h5>
</div>
</div>
</div>
@@ -205,8 +139,9 @@ export default {
<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.RuntimeStats.Memory)
}} </h5>
<h5 class="card-title">
{{ getFileSize(mailbox.appInfo.RuntimeStats.Memory) }}
</h5>
</div>
</div>
</div>
@@ -216,7 +151,8 @@ export default {
<div class="card border-secondary">
<div class="card-header h4">
Runtime statistics
<button class="btn btn-sm btn-outline-secondary float-end" v-on:click="loadInfo">
<button class="btn btn-sm btn-outline-secondary float-end"
v-on:click="loadInfo">
Refresh
</button>
</div>
@@ -247,8 +183,8 @@ export default {
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
</small>
</td>
</tr>
@@ -285,8 +221,8 @@ export default {
</div>
</div>
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true">
<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">
@@ -298,7 +234,8 @@ export default {
<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.
and that you must have Mailpit open in a browser tab to be able to receive the
notifications.
</p>
</div>
<div class="modal-footer">
@@ -309,6 +246,8 @@ export default {
</div>
</div>
</div>
<Settings />
</template>
<AjaxLoader :loading="loading" />

View File

@@ -0,0 +1,119 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
editableTags: [],
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
tagToDelete: false,
}
},
watch: {
'mailbox.tags': {
handler(tags) {
this.editableTags = []
tags.forEach((t) => {
this.editableTags.push({ before: t, after: t })
})
},
deep: true
}
},
methods: {
validTag(t) {
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
return false
}
const lower = t.after.toLowerCase()
for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
return false
}
}
return true
},
renameTag(t) {
if (!this.validTag(t) || t.before == t.after) {
return
}
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
// the API triggers a reload via websockets
})
},
deleteTag() {
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
// the API triggers a reload via websockets
this.tagToDelete = false
})
},
resetTagEdit(t) {
for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
this.editableTags[x].after = this.editableTags[x].before
}
}
}
}
}
</script>
<template>
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
itself, and not any messages which had the tag.
</p>
<div class="mb-3" v-for="t in editableTags">
<div class="input-group has-validation">
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
@focus="resetTagEdit(t)">
<button v-if="t.before != t.after" class="btn btn-success"
@click="renameTag(t)">Save</button>
<template v-else>
<button class="btn btn-outline-danger"
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
<template v-if="tagToDelete == t">
Confirm?
</template>
<template v-else>
Delete
</template>
</button>
</template>
<div class="invalid-feedback">
Invalid tag name
</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>
</template>

View File

@@ -39,10 +39,9 @@ export default {
}
this.iconProcessing = true
let self = this
window.setTimeout(() => {
self.icoUpdate()
this.icoUpdate()
}, this.iconTimeout)
},
},

View File

@@ -1,7 +1,8 @@
<script>
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import moment from 'moment'
import dayjs from 'dayjs'
import { pagination } from "../stores/pagination";
export default {
mixins: [
@@ -15,39 +16,30 @@ export default {
data() {
return {
mailbox,
pagination,
}
},
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"
}
})
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.refreshUI()
},
methods: {
getRelativeCreated: function (message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString()
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
},
getPrimaryEmailTo: function (message) {
getRelativeCreated(message) {
const d = new Date(message.Created)
return dayjs(d).fromNow()
},
getPrimaryEmailTo(message) {
for (let i in message.To) {
return message.To[i].Address
}
@@ -55,11 +47,11 @@ export default {
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
toggleSelected(e, id) {
e.preventDefault()
if (this.isSelected(id)) {
@@ -71,7 +63,7 @@ export default {
}
},
selectRange: function (e, id) {
selectRange(e, id) {
e.preventDefault()
let selecting = false
@@ -105,6 +97,20 @@ export default {
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -112,7 +118,8 @@ export default {
<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"
<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)">
@@ -122,16 +129,14 @@ export default {
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
<span v-if="message.From" :title="'From: ' + 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="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
<b v-if="message.From" :title="'From: ' + 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) }}
@@ -148,7 +153,8 @@ export default {
{{ 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)"
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
v-on:click="pagination.start = 0"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ t }}

View File

@@ -30,30 +30,33 @@ export default {
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
reloadInbox() {
const paginationParams = this.getPaginationParams()
const reload = paginationParams?.start ? false : true
this.$router.push('/')
if (reload) {
// already on first page, reload messages
this.loadMessages()
}
},
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
markAllRead() {
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
deleteAllMessages() {
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
pagination.start = 0
self.loadMessages()
this.loadMessages()
})
}
}
@@ -62,7 +65,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
@@ -114,7 +122,8 @@ export default {
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<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">
@@ -122,8 +131,8 @@ export default {
<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>.
This will permanently delete {{ formatNumber(mailbox.total) }}
message<span v-if="mailbox.total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>

View File

@@ -30,22 +30,20 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
deleteAllMessages: function () {
let s = this.getSearch()
deleteAllMessages() {
const s = this.getSearch()
if (!s) {
return
}
let self = this
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, function (response) {
self.$router.push('/')
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, (response) => {
this.$router.push('/')
})
}
}
@@ -54,7 +52,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Inbox</span>
@@ -77,7 +80,8 @@ export default {
<template v-else>
<!-- Modals -->
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<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">

View File

@@ -19,54 +19,52 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.$emit('loadMessages')
},
// mark selected messages as read
markSelectedRead: function () {
let self = this
markSelectedRead() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this
markSelectedUnread() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// universal handler to delete current or selected messages
deleteMessages: function () {
deleteMessages() {
let ids = []
let self = this
ids = JSON.parse(JSON.stringify(mailbox.selected))
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// test if any selected emails are unread
selectedHasUnread: function () {
selectedHasUnread() {
if (!mailbox.selected.length) {
return false
}
@@ -79,7 +77,7 @@ export default {
},
// test of any selected emails are read
selectedHasRead: function () {
selectedHasRead() {
if (!mailbox.selected.length) {
return false
}

View File

@@ -11,27 +11,13 @@ export default {
data() {
return {
mailbox,
pagination,
}
},
methods: {
// if the current filter is active then reload view
reloadFilter: function (tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`^tag:"?${tag}"?$`, 'i')
if (query.match(re)) {
pagination.start = 0
this.$emit('loadMessages')
}
},
// test whether a tag is currently being searched for (in the URL)
inSearch: function (tag) {
inSearch(tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
@@ -43,7 +29,7 @@ export default {
},
// toggle a tag search in the search URL, add or remove it accordingly
toggleTag: function (e, tag) {
toggleTag(e, tag) {
e.preventDefault()
const urlParams = new URLSearchParams(window.location.search)
@@ -67,9 +53,28 @@ export default {
if (query == '') {
this.$router.push('/')
} else {
this.$router.push('/search?q=' + encodeURIComponent(query))
const params = new URLSearchParams({
q: query,
start: pagination.start.toString(),
limit: pagination.limit.toString(),
})
this.$router.push('/search?' + params.toString())
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -81,6 +86,11 @@ export default {
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
Edit tags
</button>
</li>
<li>
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
<template v-if="mailbox.showTagColors">Hide</template>
@@ -91,8 +101,8 @@ export default {
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
v-on:click="reloadFilter(tag)" v-on:click.ctrl="toggleTag($event, tag)"
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>

View File

@@ -14,20 +14,24 @@ export default {
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false
version: false,
paginationDelayed: false, // for delayed pagination URL changes
}
},
mounted() {
let d = document.getElementById('app')
const d = document.getElementById('app')
if (d) {
this.version = d.dataset.version
}
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.socketBreakReset()
this.connect()
mailbox.notificationsSupported = window.isSecureContext
@@ -37,10 +41,9 @@ export default {
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
connect() {
const ws = new WebSocket(this.socketURI)
ws.onmessage = (e) => {
let response
try {
response = JSON.parse(e.data)
@@ -60,24 +63,28 @@ export default {
} else {
// update pagination offset
pagination.start++
// prevent "Too many calls to Location or History APIs within a short timeframe"
this.delayedPaginationUpdate()
}
}
for (let i in response.Data.Tags) {
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
mailbox.tags.push(response.Data.Tags[i])
mailbox.tags.sort()
mailbox.tags.sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase())
})
}
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
if (!this.pauseNotifications) {
this.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
this.browserNotify("New mail from: " + from, response.Data.Subject)
this.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
@@ -90,27 +97,52 @@ export default {
mailbox.unread = response.Data.Unread
// detect version updated, refresh is needed
if (self.version != response.Data.Version) {
if (this.version != response.Data.Version) {
location.reload()
}
}
}
ws.onopen = function () {
ws.onopen = () => {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
this.socketLastConnection = Date.now()
if (this.reconnectRefresh) {
this.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
ws.onclose = (e) => {
if (this.socketLastConnection == 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log('Unable to connect to websocket, disabling websocket support')
return
}
setTimeout(function () {
self.connect() // reconnect
if (mailbox.connected) {
// count disconnections
this.socketBreaks++
}
// set disconnected state
mailbox.connected = false
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log('Unstable websocket connection, disabling websocket support')
return
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true
} else {
this.reconnectRefresh = false
}
setTimeout(() => {
this.connect() // reconnect
}, 1000)
}
@@ -119,13 +151,52 @@ export default {
}
},
browserNotify: function (title, message) {
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0
this.socketBreakReset()
}, 15000)
},
// This will only update the pagination offset at a maximum of 2x per second
// when viewing the inbox on > page 1, while receiving an influx of new messages.
delayedPaginationUpdate() {
if (this.paginationDelayed) {
return
}
this.paginationDelayed = true
window.setTimeout(() => {
const path = this.$route.path
const p = {
...this.$route.query
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
} else {
delete p.start
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
} else {
delete p.limit
}
mailbox.autoPaginating = false // prevent reload of messages when URL changes
const params = new URLSearchParams(p)
this.$router.replace(path + '?' + params.toString())
this.paginationDelayed = false
}, 500)
},
browserNotify(title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
@@ -134,7 +205,7 @@ export default {
}
},
setMessageToast: function (m) {
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
@@ -142,19 +213,18 @@ export default {
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
const el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
this.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
closeToast() {
const el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}

View File

@@ -1,7 +1,7 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
import { limitOptions, pagination } from '../stores/pagination'
export default {
@@ -11,26 +11,25 @@ export default {
total: Number,
},
emits: ['loadMessages'],
data() {
return {
pagination,
mailbox,
limitOptions,
}
},
computed: {
canPrev: function () {
canPrev() {
return pagination.start > 0
},
canNext: function () {
canNext() {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages: function () {
nextMessages() {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
@@ -41,23 +40,42 @@ export default {
},
methods: {
changeLimit: function () {
changeLimit() {
pagination.start = 0
this.$emit('loadMessages')
this.updateQueryParams()
},
viewNext: function () {
viewNext() {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.$emit('loadMessages')
this.updateQueryParams()
},
viewPrev: function () {
viewPrev() {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
}
pagination.start = s
this.$emit('loadMessages')
this.updateQueryParams()
},
updateQueryParams() {
const path = this.$route.path
const p = {
...this.$route.query
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
} else {
delete p.start
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
} else {
delete p.limit
}
const params = new URLSearchParams(p)
this.$router.push(path + '?' + params.toString())
},
}
}
@@ -66,10 +84,7 @@ export default {
<template>
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
:disabled="total == 0">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
</select>
<small>

View File

@@ -24,29 +24,40 @@ export default {
},
methods: {
searchFromURL: function () {
searchFromURL() {
const urlParams = new URLSearchParams(window.location.search)
this.search = urlParams.get('q') ? urlParams.get('q') : ''
},
doSearch: function (e) {
doSearch(e) {
pagination.start = 0
if (this.search == '') {
this.$router.push('/')
} else {
const urlParams = new URLSearchParams(window.location.search)
let curr = urlParams.get('q')
const curr = urlParams.get('q')
if (curr && curr == this.search) {
pagination.start = 0
this.$emit('loadMessages')
}
this.$router.push('/search?q=' + encodeURIComponent(this.search))
const p = {
q: this.search
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/search?' + params.toString())
}
e.preventDefault()
},
resetSearch: function () {
resetSearch() {
this.search = ''
this.$router.push('/')
}

View File

@@ -0,0 +1,119 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import Tags from 'bootstrap5-tags'
import timezones from 'timezones-list'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
timezones,
}
},
watch: {
theme(v) {
if (v == 'auto') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', v)
}
this.setTheme()
}
},
mounted() {
this.setTheme()
this.$nextTick(function () {
Tags.init('select.tz')
})
},
methods: {
setTheme() {
if (
this.theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', this.theme)
}
},
}
}
</script>
<template>
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="SettingsModalLabel">Mailpit UI settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="theme" class="form-label">Mailpit theme</label>
<select class="form-select" v-model="theme" id="theme">
<option value="auto">Auto (detect from browser)</option>
<option value="light">Light theme</option>
<option value="dark">Dark theme</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone" data-allow-same="true">
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
</select>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
v-model="mailbox.showTagColors">
<label class="form-check-label" for="tagColors">
Use auto-generated tag colors
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
v-model="mailbox.showHTMLCheck">
<label class="form-check-label" for="htmlCheck">
Show HTML check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
v-model="mailbox.showLinkCheck">
<label class="form-check-label" for="linkCheck">
Show link check message tab
</label>
</div>
</div>
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
v-model="mailbox.showSpamCheck">
<label class="form-check-label" for="spamCheck">
Show spam check message tab
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -20,7 +20,6 @@ export default {
data() {
return {
error: false,
enabled: true,
check: false,
platforms: [],
allPlatforms: {
@@ -37,16 +36,13 @@ export default {
},
mounted() {
this.enabled = !localStorage.getItem('htmlCheckDisabled')
this.loadConfig()
this.doCheck()
},
computed: {
summary: function () {
let self = this
if (!this.enabled || !this.check) {
summary() {
if (!this.check) {
return false
}
@@ -67,8 +63,8 @@ export default {
}
// filter by enabled platforms
let results = o.Results.filter(function (w) {
return self.platforms.indexOf(w.Platform) != -1
let results = o.Results.filter((w) => {
return this.platforms.indexOf(w.Platform) != -1
})
if (results.length == 0) {
@@ -100,7 +96,7 @@ export default {
}
let maxPartial = 0, maxUnsupported = 0
result.Warnings.forEach(function (w) {
result.Warnings.forEach((w) => {
let scoreWeight = 1
if (w.Score.Found < result.Total.Nodes) {
// each error is weighted based on the number of occurrences vs: the total message nodes
@@ -110,7 +106,7 @@ export default {
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
// are actually used in the HTML, and including things like bootstrap styles completely throws
// off the calculation as these dominate.
if (self.isPseudoClassOrAtRule(w.Title)) {
if (this.isPseudoClassOrAtRule(w.Title)) {
scoreWeight = 0.05
w.PseudoClassOrAtRule = true
}
@@ -126,15 +122,15 @@ export default {
})
// sort warnings by final score
result.Warnings.sort(function (a, b) {
result.Warnings.sort((a, b) => {
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
if (self.isPseudoClassOrAtRule(a.Title)) {
if (this.isPseudoClassOrAtRule(a.Title)) {
aWeight = 0.05
}
if (self.isPseudoClassOrAtRule(b.Title)) {
if (this.isPseudoClassOrAtRule(b.Title)) {
bWeight = 0.05
}
@@ -150,7 +146,7 @@ export default {
return result
},
graphSections: function () {
graphSections() {
let s = Math.round(this.summary.Total.Supported)
let p = Math.round(this.summary.Total.Partial)
let u = 100 - s - p
@@ -174,7 +170,7 @@ export default {
},
// colors depend on both varying unsupported & partially unsupported percentages
scoreColor: function () {
scoreColor() {
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
this.$emit('setBadgeStyle', 'bg-success')
return 'text-success'
@@ -199,66 +195,51 @@ export default {
platforms(v) {
localStorage.setItem('html-check-platforms', JSON.stringify(v))
},
enabled(v) {
if (!v) {
localStorage.setItem('htmlCheckDisabled', true)
this.$emit('setHtmlScore', false)
} else {
localStorage.removeItem('htmlCheckDisabled')
this.doCheck()
}
}
},
methods: {
doCheck: function () {
if (!this.enabled) {
return
}
doCheck() {
this.check = false
if (this.message.HTML == "") {
return
}
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
.then((result) => {
this.check = result.data
this.error = false
// set tooltips
window.setTimeout(function () {
window.setTimeout(() => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
}, 500)
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
loadConfig: function () {
loadConfig() {
let platforms = localStorage.getItem('html-check-platforms')
if (platforms) {
try {
@@ -274,7 +255,7 @@ export default {
},
// return a platform's families (email clients)
families: function (k) {
families(k) {
if (this.check.Platforms[k]) {
return this.check.Platforms[k]
}
@@ -283,19 +264,19 @@ export default {
},
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
isPseudoClassOrAtRule: function (t) {
isPseudoClassOrAtRule(t) {
return t.match(/^(:|@)/)
},
round: function (v) {
round(v) {
return Math.round(v)
},
round2dm: function (v) {
round2dm(v) {
return Math.round(v * 100) / 100
},
scrollToWarnings: function () {
scrollToWarnings() {
if (!this.$refs.warnings) {
return
}
@@ -314,27 +295,13 @@ export default {
</div>
</template>
<template v-if="!enabled">
<h2 class="h4 text-secondary">HTML check is currently disabled</h2>
<p class="text-secondary">
This feature is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck">
<label class="form-check-label" for="inlineEnableHTMLCheck">
Enable HTML check
</label>
</div>
</template>
<template v-if="summary">
<div class="mt-5 mb-3">
<div class="row w-100">
<div class="col-md-8">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
@section-click="scrollToWarnings">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ round2dm(summary.Total.Supported) }}%
</h2>
@@ -368,10 +335,6 @@ export default {
<i class="bi bi-info-circle-fill"></i>
Help
</button>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions">
<i class="bi bi-gear-fill"></i>
Settings
</button>
</div>
</div>
<div class="col-md">
@@ -412,8 +375,9 @@ export default {
<div class="col-sm mt-2 mt-sm-0">
<div class="progress-stacked">
<div class="progress" role="progressbar" aria-label="Supported"
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
title="Supported">
<div class="progress-bar bg-success">
{{ round(warning.Score.Supported) + '%' }}
</div>
@@ -426,8 +390,9 @@ export default {
</div>
</div>
<div class="progress" role="progressbar" aria-label="No"
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
title="Not supported">
<div class="progress-bar bg-danger">
{{ round(warning.Score.Unsupported) + '%' }}
</div>
@@ -444,7 +409,8 @@ export default {
<i class="bi bi-info-circle me-2"></i>
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
propert<template v-if="warning.Score.Found === 1">y</template><template
v-else>ies</template> in the CSS styles, but unable to test if used or not.
v-else>ies</template> in the CSS
styles, but unable to test if used or not.
</span>
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
</p>
@@ -500,10 +466,6 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
HTML check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="HTMLCheckAboutAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
@@ -515,9 +477,9 @@ export default {
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
The support for HTML/CSS messages varies greatly across email clients. HTML check
attempts to calculate the overall support for your email for all selected platforms
to give you some idea of the general compatibility of your HTML email.
The support for HTML/CSS messages varies greatly across email clients. HTML
check attempts to calculate the overall support for your email for all selected
platforms to give you some idea of the general compatibility of your HTML email.
</div>
</div>
</div>
@@ -535,29 +497,31 @@ export default {
Internally the original HTML message is run against
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
(except for <code>&lt;script&gt;</code>) correspond to a test on
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
final score is calculated using the available compatibility data.
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and
the final score is calculated using the available compatibility data.
</p>
<p>
CSS support is very difficult to programmatically test, especially if a message
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
CSS support is very difficult to programmatically test, especially if a
message contains CSS style blocks or is linked to remote stylesheets. Remote
stylesheets are, unless blocked via
<code>--block-remote-css-and-fonts</code>,
downloaded and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer"
target="_blank">inlined</a>
to matching HTML elements. This gives Mailpit fairly accurate results.
</p>
<p>
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
<code>:hover</code> etc cannot be inlined however, so these are searched for
within CSS blocks. This method is not accurate as Mailpit does not know how many
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
to affect the score. An example of this would be any email linking to the full
bootstrap CSS which contains dozens of unused attributes.
within CSS blocks. This method is not accurate as Mailpit does not know how
many nodes it actually applies to, if any, so they are weighted lightly (5%)
as not to affect the score. An example of this would be any email linking to
the full bootstrap CSS which contains dozens of unused attributes.
</p>
<p>
All warnings are displayed with their respective support, including any specific
notes, and it is up to you to decide what you do with that information and how
badly it may impact your message.
All warnings are displayed with their respective support, including any
specific notes, and it is up to you to decide what you do with that
information and how badly it may impact your message.
</p>
</div>
</div>
@@ -580,13 +544,15 @@ export default {
<p>
For each test, Mailpit calculates both the unsupported & partially-supported
percentages in relation to the number of matches against the total number of
nodes (elements) in the HTML. The maximum unsupported and partially-supported
weighted scores are then used for the final score (ie: worst case scenario).
nodes (elements) in the HTML. The maximum unsupported and
partially-supported weighted scores are then used for the final score (ie:
worst case scenario).
</p>
<p>
To try explain this logic in very simple terms: Assuming a
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported in
any email client), and a <code>&lt;p&gt;</code> node has 100% pass (supported).
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported
in any email client), and a <code>&lt;p&gt;</code> node has 100% pass
(supported).
</p>
<ul>
<li>
@@ -603,7 +569,8 @@ export default {
</li>
</ul>
<p>
Mailpit will sort the warnings according to their weighted unsupported scores.
Mailpit will sort the warnings according to their weighted unsupported
scores.
</p>
</div>
</div>
@@ -619,9 +586,9 @@ export default {
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
HTML check does not detect if the original HTML is valid. In order to detect applied
styles to every node, the HTML email is run through a parser which is very good at
turning invalid input into valid output. It is what it is...
HTML check does not detect if the original HTML is valid. In order to detect
applied styles to every node, the HTML email is run through a parser which is
very good at turning invalid input into valid output. It is what it is...
</div>
</div>
</div>
@@ -635,36 +602,4 @@ export default {
</div>
</div>
</template>
<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="HTMLCheckOptionsLabel">HTML check options</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
HTML check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled"
id="HTMLCheckSwitch">
<label class="form-check-label" for="HTMLCheckSwitch">
<template v-if="enabled">HTML check is enabled in the web UI</template>
<template v-else>HTML check is disabled in the web UI</template>
</label>
</div>
<p class="mt-4 small text-center text-secondary">
HTML check can be globally disabled with <code>--disable-html-check</code><br>
Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,4 +1,3 @@
<script>
import commonMixins from '../../mixins/CommonMixins'
@@ -16,10 +15,9 @@ export default {
},
mounted() {
let self = this;
let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers')
self.get(uri, false, function (response) {
self.headers = response.data
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
this.get(uri, false, (response) => {
this.headers = response.data
});
},

View File

@@ -64,7 +64,7 @@ export default {
},
computed: {
groupedStatuses: function () {
groupedStatuses() {
let results = {}
if (!this.check) {
@@ -114,7 +114,7 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
this.loading = true
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
@@ -122,38 +122,37 @@ export default {
uri += '?follow=true'
}
let self = this
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {
self.check = result.data
self.error = false
.then((result) => {
this.check = result.data
this.error = false
self.$emit('setLinkErrors', result.data.Errors)
this.$emit('setLinkErrors', result.data.Errors)
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
.then(function (result) {
.then((result) => {
// always run
self.loading = false
this.loading = false
})
},
}
@@ -239,7 +238,8 @@ export default {
</div>
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" aria-hidden="true">
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -247,11 +247,6 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
@@ -293,10 +288,6 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="LinkCheckAboutAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
@@ -305,11 +296,12 @@ export default {
What is Link check?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
Link check scans your message HTML and text for all unique links, images and linked
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a time, to
test whether the link/image/stylesheet exists.
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
time, to test whether the link/image/stylesheet exists.
</div>
</div>
</div>
@@ -320,14 +312,16 @@ export default {
What are "301" and "302" links?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
These are links that redirect you to another URL, for example newsletters
often use redirect links to track user clicks.
</p>
<p>
By default Link check will not follow these links, however you can turn this on via
By default Link check will not follow these links, however you can turn this on
via
the settings and Link check will "follow" those redirects.
</p>
</div>
@@ -340,7 +334,8 @@ export default {
Why are some links returning an error but work in my browser?
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>This may be due to various reasons, for instance:</p>
<ul>
@@ -362,11 +357,12 @@ export default {
What are the risks of running Link check automatically?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
Depending on the type of messages you are testing, opening all links on all messages
may have undesired consequences:
Depending on the type of messages you are testing, opening all links on all
messages may have undesired consequences:
</p>
<ul>
<li>If the message contains tracking links this may reveal your identity.</li>
@@ -375,13 +371,13 @@ export default {
unsubscribe you.
</li>
<li>
To speed up the checking process, Link check will attempt 5 URLs at a time. This
could lead to temporary heady load on the remote server.
To speed up the checking process, Link check will attempt 5 URLs at a time.
This could lead to temporary heady load on the remote server.
</li>
</ul>
<p>
Unless you know what messages you receive, it is advised to only run the Link check
manually.
Unless you know what messages you receive, it is advised to only run the Link
check manually.
</p>
</div>
</div>

View File

@@ -1,4 +1,3 @@
<script>
import Attachments from './Attachments.vue'
import Headers from './Headers.vue'
@@ -32,6 +31,7 @@ export default {
srcURI: false,
iframes: [], // for resizing
canSaveTags: false, // prevent auto-saving tags on render
availableTags: [],
messageTags: [],
loadHeaders: false,
htmlScore: false,
@@ -61,65 +61,69 @@ export default {
scaleHTMLPreview(v) {
if (v == 'display') {
let self = this
window.setTimeout(function () {
self.resizeIFrames()
window.setTimeout(() => {
this.resizeIFrames()
}, 500)
}
}
},
mounted() {
let self = this
self.canSaveTags = false
self.messageTags = self.message.Tags
self.renderUI()
computed: {
hasAnyChecksEnabled() {
return (mailbox.showHTMLCheck && this.message.HTML)
|| mailbox.showLinkCheck
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
}
},
window.addEventListener("resize", self.resizeIFrames)
mounted() {
this.canSaveTags = false
this.messageTags = this.message.Tags
this.renderUI()
window.addEventListener("resize", this.resizeIFrames)
let headersTab = document.getElementById('nav-headers-tab')
headersTab.addEventListener('shown.bs.tab', function (event) {
self.loadHeaders = true
headersTab.addEventListener('shown.bs.tab', (event) => {
this.loadHeaders = true
})
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
self.resizeIFrames()
rawTab.addEventListener('shown.bs.tab', (event) => {
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
this.resizeIFrames()
})
// manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
mailbox.tags = response.data
self.$nextTick(function () {
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
this.availableTags = response.data
this.$nextTick(() => {
Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load
window.setTimeout(function () {
self.canSaveTags = true
window.setTimeout(() => {
this.canSaveTags = true
}, 200)
})
})
},
methods: {
isHTMLTabSelected: function () {
isHTMLTabSelected() {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () {
let self = this
renderUI() {
// activate the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click()
document.activeElement.blur() // blur focus
document.getElementById('message-view').scrollTop = 0
self.isHTMLTabSelected()
this.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
listObj.addEventListener('shown.bs.tab', function (event) {
self.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
listObj.addEventListener('shown.bs.tab', (event) => {
this.isHTMLTabSelected()
})
})
@@ -127,7 +131,7 @@ export default {
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () {
window.setTimeout(() => {
let p = document.getElementById('preview-html')
if (p) {
// make links open in new window
@@ -140,7 +144,7 @@ export default {
anchorEl.setAttribute('target', '_blank')
}
}
self.resizeIFrames()
this.resizeIFrames()
}
}, 200)
@@ -150,12 +154,12 @@ export default {
Prism.highlightAll()
},
resizeIframe: function (el) {
resizeIframe(el) {
let i = el.target
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
},
resizeIFrames: function () {
resizeIFrames() {
if (this.scaleHTMLPreview != 'display') {
return
}
@@ -167,7 +171,7 @@ export default {
},
// set the iframe body & text colors based on current theme
initRawIframe: function (el) {
initRawIframe(el) {
let bodyStyles = window.getComputedStyle(document.body, null)
let bg = bodyStyles.getPropertyValue('background-color')
let txt = bodyStyles.getPropertyValue('color')
@@ -181,27 +185,25 @@ export default {
this.resizeIframe(el)
},
sanitizeHTML: function (h) {
sanitizeHTML(h) {
// remove <base/> tag if set
return h.replace(/<base .*>/mi, '')
},
saveTags: function () {
let self = this
saveTags() {
var data = {
ids: [this.message.ID],
tags: this.messageTags
IDs: [this.message.ID],
Tags: this.messageTags
}
self.put(self.resolve('/api/v1/tags'), data, function (response) {
this.put(this.resolve('/api/v1/tags'), data, (response) => {
window.scrollInPlace = true
self.$emit('loadMessages')
this.$emit('loadMessages')
})
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
textToHTML(s) {
let html = s
// full links with http(s)
@@ -239,7 +241,8 @@ export default {
<th class="small">From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " "
}}</span>
<span v-if="message.From.Address" class="small">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }}
@@ -262,7 +265,7 @@ export default {
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To && message.To.length" v-for="( t, i ) in message.To ">
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
<template v-if="i > 0">, </template>
<span>
<span class="text-spaces">{{ t.Name }}</span>
@@ -277,7 +280,7 @@ export default {
<tr v-if="message.Cc && message.Cc.length" class="small">
<th>Cc</th>
<td class="privacy">
<span v-for="( t, i ) in message.Cc ">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body">
@@ -341,7 +344,7 @@ export default {
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option>
<option v-for="t in availableTags" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid tag name</div>
</td>
@@ -422,15 +425,16 @@ export default {
role="tab" aria-controls="nav-raw" aria-selected="false">
Raw
</button>
<div class="dropdown d-xl-none">
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
Checks
</button>
<ul class="dropdown-menu checks">
<li>
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
aria-selected="false">
HTML Check
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
v-if="htmlScore !== false">
@@ -438,7 +442,7 @@ export default {
</span>
</button>
</li>
<li>
<li v-if="mailbox.showLinkCheck">
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
aria-selected="false">
@@ -451,7 +455,7 @@ export default {
</span>
</button>
</li>
<li v-if="mailbox.uiConfig.SpamAssassin">
<li v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false">
@@ -465,8 +469,8 @@ export default {
</ul>
</div>
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab"
aria-controls="nav-html" aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
@@ -474,7 +478,7 @@ export default {
</button>
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
aria-selected="false">
aria-selected="false" v-if="mailbox.showLinkCheck">
Link Check
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
@@ -482,8 +486,9 @@ export default {
</span>
</button>
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="mailbox.uiConfig.SpamAssassin">
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab"
aria-controls="nav-html" aria-selected="false"
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
Spam Analysis
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
<small>{{ spamScore }}</small>
@@ -505,22 +510,25 @@ export default {
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizeHTML(message.HTML)"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
style="width: 100%; height: 100%; background: #fff;">
</iframe>
</div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
:attachments="allAttachments(message)">
</Attachments>
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class="message.HTML == '' ? 'show' : ''">
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
<div class="text-view" v-html="textToHTML(message.Text)"></div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
:attachments="allAttachments(message)">
</Attachments>
</div>
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if="loadHeaders" :message="message"></Headers>
@@ -531,16 +539,16 @@ export default {
</div>
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
tabindex="0">
<SpamAssassin v-if="mailbox.uiConfig.SpamAssassin" :message="message" @setSpamScore="(n) => spamScore = n"
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
<SpamAssassin :message="message" @setSpamScore="(n) => spamScore = n"
@set-badge-style="(v) => spamScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
tabindex="0" v-if="mailbox.showLinkCheck">
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
</div>
</div>

View File

@@ -1,4 +1,3 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import Tags from "bootstrap5-tags"
@@ -47,26 +46,25 @@ export default {
methods: {
// triggered manually after modal is shown
initTags: function () {
initTags() {
Tags.init("select[multiple]")
},
releaseMessage: function () {
let self = this
releaseMessage() {
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
window.setTimeout(() => {
if (!this.addresses.length) {
return false
}
let data = {
to: self.addresses
To: this.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
self.modal("ReleaseModal").hide()
if (self.deleteAfterRelease) {
self.$emit('delete')
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
this.modal("ReleaseModal").hide()
if (this.deleteAfterRelease) {
this.$emit('delete')
}
})
}, 100)
@@ -77,7 +75,7 @@ export default {
<template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-dialog modal-xl" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
@@ -103,8 +101,9 @@ export default {
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
<div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Enter email addresses..."
data-add-on-blur="true" data-badge-style="primary"
data-clear-end="true" data-allow-clear="true"
data-placeholder="Enter email addresses..." data-add-on-blur="true"
data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|">
<option value="">Enter email addresses...</option>
@@ -126,19 +125,48 @@ export default {
</div>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
<h6>Notes</h6>
<ul>
<li v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''" class="form-text">
A recipient <b>allowlist</b> has been configured. Any mail address not matching the following will be rejected:
<code>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</code>
</li>
<li v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''" class="form-text">
A recipient <b>blocklist</b> has been configured. Any mail address matching the following will be rejected:
<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>
</li>
<li class="form-text">
For testing purposes, a new unique <code>Message-Id</code> will be generated on send.
</li>
<li class="form-text">
SMTP delivery failures will bounce back to
<code v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
</code>
<code v-else>{{ message.ReturnPath }}</code>.
</li>
</ul>
<!-- <div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be
rejected.<br class="d-none d-md-inline">
Allowed recipients: <b>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</b>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''">
Note: A recipient blocklist has been configured. Any mail address matching it will be
rejected.<br class="d-none d-md-inline">
Blocked recipients: <b>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">{{ mailbox.uiConfig.MessageRelay.ReturnPath
}}</b>
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div> -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>

View File

@@ -1,4 +1,3 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
@@ -23,9 +22,8 @@ export default {
},
methods: {
initScreenshot: function () {
initScreenshot() {
this.loading = 1
let self = this
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
@@ -38,11 +36,11 @@ export default {
// update any inline `url(...)` absolute links
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, function (match, p1, p2, p3) {
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `)`
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
@@ -63,7 +61,7 @@ export default {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -72,7 +70,7 @@ export default {
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -83,7 +81,7 @@ export default {
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -92,7 +90,7 @@ export default {
},
// HTML decode function
decodeEntities: function (s) {
decodeEntities(s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
@@ -100,8 +98,7 @@ export default {
return str
},
doScreenshot: function () {
let self = this
doScreenshot() {
let width = document.getElementById('message-view').getBoundingClientRect().width
let prev = document.getElementById('preview-html')
@@ -113,7 +110,7 @@ export default {
width = 300
}
let i = document.getElementById('screenshot-html')
const i = document.getElementById('screenshot-html')
// set the iframe width
i.style.width = width + 'px'
@@ -127,11 +124,11 @@ export default {
width: width,
}).then(dataUrl => {
const link = document.createElement('a')
link.download = self.message.ID + '.png'
link.download = this.message.ID + '.png'
link.href = dataUrl
link.click()
self.loading = 0
self.html = false
this.loading = 0
this.html = false
})
}
}

View File

@@ -38,41 +38,39 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/sa-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
self.setIcons()
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
.then((result) => {
this.check = result.data
this.error = false
this.setIcons()
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
badgeStyle: function (ignorePadding = false) {
badgeStyle(ignorePadding = false) {
let badgeStyle = 'bg-success'
if (this.check.Error) {
badgeStyle = 'bg-warning text-primary'
@@ -90,7 +88,7 @@ export default {
return badgeStyle
},
setIcons: function () {
setIcons() {
let score = this.check.Score
if (this.check.Error && this.check.Error != '') {
score = '!'
@@ -102,7 +100,7 @@ export default {
},
computed: {
graphSections: function () {
graphSections() {
let score = this.check.Score
let p = Math.round(score / 5 * 100)
if (p > 100) {
@@ -122,7 +120,11 @@ export default {
value: p,
color: c
},
];
]
},
scoreColor() {
return this.graphSections[0].color
},
}
}
@@ -202,10 +204,6 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Spam Analysis is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="SpamAnalysisAboutAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
@@ -214,13 +212,14 @@ export default {
What is Spam Analysis?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div class="accordion-body">
<p>
Mailpit integrates with SpamAssassin to provide you with some insight into the
"spamminess" of your messages. It sends your complete message (including any
attachments) to a running SpamAssassin server and then displays the results returned
by SpamAssassin.
attachments) to a running SpamAssassin server and then displays the results
returned by SpamAssassin.
</p>
</div>
</div>
@@ -232,16 +231,17 @@ export default {
How does the point system work?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div class="accordion-body">
<p>
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
considered ham (not spam), and any score of 5 or above is spam.
</p>
<p>
SpamAssassin will also return the tests which are triggered by the message. These
tests can differ depending on the configuration of your SpamAssassin server. The
total of this score makes up the the "spamminess" of the message.
SpamAssassin will also return the tests which are triggered by the message.
These tests can differ depending on the configuration of your SpamAssassin
server. The total of this score makes up the the "spamminess" of the message.
</p>
</div>
</div>
@@ -253,7 +253,8 @@ export default {
But I don't agree with the results...
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div class="accordion-body">
<p>
Mailpit does not manipulate the results nor determine the "spamminess" of
@@ -261,8 +262,9 @@ export default {
dependent on how SpamAssassin is set up and optionally trained.
</p>
<p>
This tool is simply provided as an aid to assist you. If you are running your own
instance of SpamAssassin, then you look into your SpamAssassin configuration.
This tool is simply provided as an aid to assist you. If you are running your
own instance of SpamAssassin, then you look into your SpamAssassin
configuration.
</p>
</div>
</div>
@@ -274,14 +276,15 @@ export default {
Where can I find more information about the triggered rules?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#SpamAnalysisAboutAccordion">
<div class="accordion-body">
<p>
Unfortunately the current <a href="https://spamassassin.apache.org/"
target="_blank">SpamAssassin website</a> no longer contains any relative
documentation
about these, most likely because the rules come from different locations and change
often. You will need to search the internet for these yourself.
documentation about these, most likely because the rules come from different
locations and change often. You will need to search the internet for these
yourself.
</p>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import axios from 'axios'
import moment from 'moment'
import dayjs from 'dayjs'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
import { limitOptions } from "../stores/pagination";
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
@@ -24,15 +25,15 @@ export default {
},
methods: {
resolve: function (u) {
resolve(u) {
return this.$router.resolve(u).href
},
searchURI: function (s) {
searchURI(s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
},
getFileSize: function (bytes) {
getFileSize(bytes) {
if (bytes == 0) {
return '0B'
}
@@ -40,19 +41,19 @@ export default {
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
},
formatNumber: function (nr) {
formatNumber(nr) {
return new Intl.NumberFormat().format(nr)
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a')
messageDate(d) {
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
},
secondsToRelative: function (d) {
return moment().subtract(d, 'seconds').fromNow()
secondsToRelative(d) {
return dayjs().subtract(d, 'seconds').fromNow()
},
tagEncodeURI: function (tag) {
tagEncodeURI(tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
}
@@ -60,23 +61,37 @@ export default {
return encodeURIComponent(`tag:${tag}`)
},
getSearch: function () {
getSearch() {
if (!window.location.search) {
return false
}
const urlParams = new URLSearchParams(window.location.search)
const q = urlParams.get('q').trim()
if (q == '') {
const q = urlParams.get('q')?.trim()
if (!q) {
return false
}
return q
},
getPaginationParams() {
if (!window.location.search) {
return null
}
const urlParams = new URLSearchParams(window.location.search)
const start = parseInt(urlParams.get('start')?.trim(), 10)
const limit = parseInt(urlParams.get('limit')?.trim(), 10)
return {
start: Number.isInteger(start) && start >= 0 ? start : null,
limit: limitOptions.includes(limit) ? limit : null,
}
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id)
modal(id) {
const e = document.getElementById(id)
if (e) {
return Modal.getOrCreateInstance(e)
}
@@ -85,8 +100,8 @@ export default {
},
// close mobile navigation
hideNav: function () {
let e = document.getElementById('offcanvas')
hideNav() {
const e = document.getElementById('offcanvas')
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
}
@@ -100,22 +115,21 @@ export default {
* @params function callback function
* @params function error callback function
*/
get: function (url, values, callback, errorCallback) {
let self = this
self.loading++
get(url, values, callback, errorCallback) {
this.loading++
axios.get(url, { params: values })
.then(callback)
.catch(function (err) {
.catch((err) => {
if (typeof errorCallback == 'function') {
return errorCallback(err)
}
self.handleError(err)
this.handleError(err)
})
.then(function () {
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -127,16 +141,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
post: function (url, data, callback) {
let self = this
self.loading++
post(url, data, callback) {
this.loading++
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -148,16 +161,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this
self.loading++
delete(url, data, callback) {
this.loading++
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -169,22 +181,21 @@ export default {
* @params array object/array values
* @params function callback function
*/
put: function (url, data, callback) {
let self = this
self.loading++
put(url, data, callback) {
this.loading++
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
// Ajax error message
handleError: function (error) {
handleError(error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
@@ -203,7 +214,7 @@ export default {
}
},
allAttachments: function (message) {
allAttachments(message) {
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i])
@@ -222,7 +233,7 @@ export default {
return a.ContentType.match(/^image\//)
},
attachmentIcon: function (a) {
attachmentIcon(a) {
let ext = a.FileName.split('.').pop().toLowerCase()
if (a.ContentType.match(/^image\//)) {
@@ -243,6 +254,9 @@ export default {
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill'
}
if (['ics'].includes(ext)) {
return 'bi-calendar-event'
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill'
}
@@ -261,7 +275,7 @@ export default {
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
colorHash(s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}

View File

@@ -25,19 +25,25 @@ export default {
},
methods: {
reloadMailbox: function () {
reloadMailbox() {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
loadMessages() {
if (!this.apiURI) {
alert('apiURL not set!')
return
}
let self = this
let params = {}
// auto-pagination changes the URL but should not fetch new messages
// when viewing page > 0 and new messages are received (inbox only)
if (!mailbox.autoPaginating) {
mailbox.autoPaginating = true // reset
return
}
const params = {}
mailbox.selected = []
params['limit'] = pagination.limit
@@ -45,7 +51,7 @@ export default {
params['start'] = pagination.start
}
self.get(this.apiURI, params, function (response) {
this.get(this.apiURI, params, (response) => {
mailbox.total = response.data.total // all messages
mailbox.unread = response.data.unread // all unread messages
mailbox.tags = response.data.tags // all tags
@@ -56,18 +62,18 @@ export default {
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return self.loadMessages()
return this.loadMessages()
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
let m = document.getElementById(mailbox.lastMessage)
const m = document.getElementById(mailbox.lastMessage)
if (m) {
m.focus()
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
} else {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
@@ -77,7 +83,7 @@ export default {
}, 50)
} else if (!window.scrollInPlace) {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -4,21 +4,28 @@ import { reactive, watch } from 'vue'
// global mailbox info
export const mailbox = reactive({
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
showTagColors: true, // show/hide tag colors
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
notificationsSupported: false,
notificationsEnabled: false,
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
// settings
showTagColors: !localStorage.getItem('hideTagColors') == '1',
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1',
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1',
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1',
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone,
})
watch(
@@ -38,3 +45,47 @@ watch(
}
}
)
watch(
() => mailbox.showHTMLCheck,
(v) => {
if (v) {
localStorage.removeItem('hideHTMLCheck')
} else {
localStorage.setItem('hideHTMLCheck', '1')
}
}
)
watch(
() => mailbox.showLinkCheck,
(v) => {
if (v) {
localStorage.removeItem('hideLinkCheck')
} else {
localStorage.setItem('hideLinkCheck', '1')
}
}
)
watch(
() => mailbox.showSpamCheck,
(v) => {
if (v) {
localStorage.removeItem('hideSpamCheck')
} else {
localStorage.setItem('hideSpamCheck', '1')
}
}
)
watch(
() => mailbox.timeZone,
(v) => {
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) {
localStorage.removeItem('timeZone')
} else {
localStorage.setItem('timeZone', v)
}
}
)

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