Compare commits

..

1353 Commits

Author SHA1 Message Date
Ralph Slooten
56ca3afbad Merge branch 'release/v1.23.2' 2025-03-16 21:16:09 +13:00
Ralph Slooten
b7fa68dff9 Release v1.23.2 2025-03-16 21:16:08 +13:00
Ralph Slooten
5214739618 Update node dependencies 2025-03-16 21:13:51 +13:00
Ralph Slooten
2bb2036380 Chore: Update Go dependencies 2025-03-16 21:11:50 +13:00
Ralph Slooten
de693c9c68 Push ghcr.io :latest tag last to feature first 2025-03-15 12:03:38 +13:00
Ralph Slooten
bb5ea68f03 Merge branch 'feature/htmlcheck' into develop 2025-03-15 11:55:44 +13:00
Ralph Slooten
b4131dbeae Testing: Add tests for inline HTML Checks 2025-03-15 11:55:03 +13:00
Ralph Slooten
e3e1d734b6 Chore: Improve inline HTML Check style detection (#467)
Using goquery sometimes resulted in incorrect partial matches, eg `transform:` matching `text-transform:`. This refactor switches to regex matches which should prevent this, and allow more accurate detection.
2025-03-15 11:54:22 +13:00
Ralph Slooten
25671ba94f Chore: Use Message-ID header instead of Message-Id when generating new IDs (RFC 5322) 2025-03-13 17:34:10 +13:00
Ralph Slooten
290ffdd80c Chore: Update node dependencies 2025-03-12 17:09:31 +13:00
Ralph Slooten
753591105a Merge tag 'v1.23.1' into develop
Release v1.23.1
2025-03-08 23:01:20 +13:00
Ralph Slooten
5d0bbe74e0 Merge branch 'release/v1.23.1' 2025-03-08 23:01:16 +13:00
Ralph Slooten
ff1751350f Release v1.23.1 2025-03-08 23:01:15 +13:00
Ralph Slooten
fdd3cb3074 Chore: Update node dependencies 2025-03-08 22:56:32 +13:00
Ralph Slooten
4f81fb417f Chore: Update Go dependencies 2025-03-08 22:52:25 +13:00
Ralph Slooten
39886cf57c Fix: Prevent cropping bottom of label characters in web UI (#457) 2025-03-08 22:49:07 +13:00
Ralph Slooten
9a1f3a6bb5 Chore: Replace PrismJS with highlight.js for HTML syntax highlighting 2025-03-05 17:14:06 +13:00
Ralph Slooten
ac9b7de295 Fix: Allow searching messages using only Cyrillic characters (#450) 2025-03-04 16:51:19 +13:00
Ralph Slooten
d4406cf02b Merge tag 'v1.23.0' into develop
Release v1.23.0
2025-03-01 23:27:56 +13:00
Ralph Slooten
577461bff4 Merge branch 'release/v1.23.0' 2025-03-01 23:27:44 +13:00
Ralph Slooten
289466bdb8 Release v1.23.0 2025-03-01 23:27:43 +13:00
Ralph Slooten
3c2e227d32 Ignore gosec warnings for dump folder / file permissions 2025-03-01 23:11:24 +13:00
Ralph Slooten
7dfdf54e97 Chore: Update node dependencies 2025-03-01 23:02:34 +13:00
Ralph Slooten
f61a390bd9 Chore: Update Go dependencies 2025-03-01 22:59:46 +13:00
Ralph Slooten
b827d75c3e Feature: Add configuration to disable SQLite WAL mode for NFS compatibility 2025-03-01 22:51:42 +13:00
Ralph Slooten
784e3de8a1 Testing: Add tests for message compression levels 2025-03-01 22:51:41 +13:00
Ralph Slooten
876d0eb5da Feature: Add configuration to explicitly disable HTTP compression in web UI/API (#448) 2025-03-01 22:51:22 +13:00
Ralph Slooten
6e9760d5d9 Feature: Add configuration to set message compression level in db (0-3) (#447 & #448) 2025-03-01 22:51:22 +13:00
Ralph Slooten
aafd2a20d9 Chore: Minor speed & memory improvements when storing messages 2025-03-01 22:51:21 +13:00
Ralph Slooten
284e66f0ba Chore: Optimize ZSTD encoder for fastest compression of messages (#447) 2025-03-01 22:51:21 +13:00
Ralph Slooten
8995cddfa5 Chore: Handle BLOB storage for default database differently to rqlite to reduce memory overhead (#447) 2025-03-01 22:51:20 +13:00
Ralph Slooten
8401ffff22 Fix: Display the correct STARTTLS or TLS runtime option on startup (#446)
This is just a cosmetic fix as the functionality itself was working correctly, however the runtime log said "STARTTLS required" regardless which was set.
2025-03-01 22:51:20 +13:00
Ville Skyttä
a6d0db174b Chore: Avoid shell in Docker health check (#444) 2025-03-01 22:51:19 +13:00
Ralph Slooten
c7d7810e68 Merge branch 'release/v1.22.3' 2025-02-16 09:46:13 +13:00
Ralph Slooten
d26e317d25 Release v1.22.3 2025-02-16 09:46:12 +13:00
Ralph Slooten
a051fd49a9 Chore: Update node dependencies 2025-02-16 09:39:42 +13:00
Ralph Slooten
f836e92d58 Chore: Update Go dependencies 2025-02-16 09:34:03 +13:00
Ralph Slooten
1db502ef4e Fix: Correctly detect maximum SMTP recipient limits, add test 2025-02-15 22:57:25 +13:00
Ralph Slooten
703e981a8b Allow limit=0 in URL parameters 2025-02-15 15:22:16 +13:00
Ralph Slooten
8878ece19f Feature: Add dump feature to export all raw messages to a local directory (#443) 2025-02-15 14:33:11 +13:00
Ralph Slooten
7c366669c7 Fix: Update Swagger JSON to prevent overflow (#442) 2025-02-14 16:10:54 +13:00
Ralph Slooten
61a1ed0e49 Remove duplication of swagger:model Triggers 2025-02-14 15:44:19 +13:00
Ralph Slooten
9b2e90279d Fix: Include font/woff content type to embedded controller 2025-02-13 22:16:46 +13:00
Ville Skyttä
a1d35d488d Chore: Specify Docker health check start period and interval (#439)
To reach healthy state faster at startup.
2025-02-13 15:57:45 +13:00
Ralph Slooten
a3bd62482d Fix: Replace TrimLeft with TrimPrefix for webroot path handling (#441) 2025-02-13 15:55:12 +13:00
Ralph Slooten
d0458e2e7a Merge tag 'v1.22.2' into develop
Release v1.22.2
2025-02-09 10:10:43 +13:00
Ralph Slooten
f40f95555a Merge branch 'release/v1.22.2' 2025-02-09 10:10:35 +13:00
Ralph Slooten
a5558d97ce Release v1.22.2 2025-02-09 10:10:34 +13:00
Ralph Slooten
50c072ef4f Chore: Update node dependencies / esbuild 2025-02-09 10:07:54 +13:00
Ralph Slooten
561032f367 Chore: Update Go dependencies 2025-02-09 10:00:35 +13:00
Ralph Slooten
8f1b7b6ec0 Chore: Enable browser cache for embedded web UI assets 2025-02-09 09:47:45 +13:00
Ralph Slooten
be94385f38 Merge branch 'feature/embed-controller' into develop 2025-02-08 15:15:34 +13:00
Ralph Slooten
61306e1ae4 Ignore render errors 2025-02-08 15:15:25 +13:00
Ralph Slooten
dac9fcf735 Chore: Replace http.FileServer with custom controller to correctly encode gzipped error responses for embed.FS
Go v1.23 removes the Content-Encoding header from error responses, breaking pages such as 404's while using gzip compression middleware.
2025-02-08 15:15:07 +13:00
Ralph Slooten
3528bc8da7 Fix: Add missing "latest" route to message attachment API endpoint (#437) 2025-02-08 08:35:37 +13:00
Ralph Slooten
cb3300212f Fix: Remove recursive HTML regeneration in embedded HTML view (#434) 2025-02-07 19:39:55 +13:00
Ralph Slooten
f377414c3b Merge tag 'v1.22.1' into develop
Release v1.22.1
2025-02-06 15:09:08 +13:00
Ralph Slooten
a2db203a08 Merge branch 'release/v1.22.1' 2025-02-06 15:09:06 +13:00
Ralph Slooten
b1eb58c9c8 Release v1.22.1 2025-02-06 15:09:06 +13:00
Ralph Slooten
76b7e74049 Chore: Update node dependencies 2025-02-06 15:04:37 +13:00
Ralph Slooten
ed0caa0081 Chore: Update Go dependencies 2025-02-06 15:03:45 +13:00
Ralph Slooten
45e67b5cac Remove swagger example to allow validation 2025-02-05 15:36:07 +13:00
Ralph Slooten
0c63c29769 Feature: Add optional query parameter for HTML message iframe embedding (#434) 2025-02-05 15:25:15 +13:00
Ralph Slooten
f4d6dd5c39 Update test error logging formatting 2025-02-04 16:16:17 +13:00
Ralph Slooten
496bf17db7 Chore: Add API CORS policy to HTML preview routes (#434) 2025-02-02 15:57:40 +13:00
Ralph Slooten
86b5524217 Feature: Add optional UI setting to skip "Delete all" & "Mark all read" confirmation dialogs(#428) 2025-02-02 15:31:18 +13:00
dependabot[bot]
cba9f0043c Chore: Bump actions/stale from 9.0.0 to 9.1.0 (#432)
Bumps [actions/stale](https://github.com/actions/stale) from 9.0.0 to 9.1.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9.0.0...v9.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-01 22:52:28 +13:00
Ralph Slooten
a1b08ea2bc Fix Chaos link typo 2025-01-30 23:20:00 +13:00
Ralph Slooten
3d6d899a6d Merge tag 'v1.22.0' into develop
Release v1.22.0
2025-01-26 13:37:08 +13:00
Ralph Slooten
9687329fc1 Merge branch 'release/v1.22.0' 2025-01-26 13:37:04 +13:00
Ralph Slooten
04410ff463 Release v1.22.0 2025-01-26 13:37:03 +13:00
Ralph Slooten
a29b969e61 Reorder forwarding feature 2025-01-26 12:46:13 +13:00
Ralph Slooten
8425780ccd Chore: Update node dependencies 2025-01-26 12:44:41 +13:00
Ralph Slooten
8331e11f7f Chore: Update Go dependencies 2025-01-26 12:43:29 +13:00
Ralph Slooten
d7df895261 Feature: SMTP auto-forwarding option (#414) 2025-01-26 12:39:39 +13:00
Ralph Slooten
e2fab49873 Update relay modal wording 2025-01-26 09:48:05 +13:00
Ralph Slooten
a95bc3d29f Feature: Option to override the From email address in SMTP relay configuration (#414) 2025-01-26 00:22:57 +13:00
Ralph Slooten
f278933bb9 Merge branch 'feature/chaos' into develop 2025-01-25 12:17:32 +13:00
Ralph Slooten
4d86297169 Feature: Add Chaos functionality to test integration handling of SMTP error responses (#402, #110, #144 & #268)
Closes #405
2025-01-25 12:17:15 +13:00
Ralph Slooten
2a6ab0476b Correct format string in EHLO response 2025-01-25 11:57:32 +13:00
Ralph Slooten
b2ffb7476d Order swagger sections by tag name 2025-01-25 00:00:23 +13:00
Ralph Slooten
338f205234 Use consistent swagger tag casing 2025-01-24 11:55:51 +13:00
Ralph Slooten
168049faf9 Refactor write & writef arguments 2025-01-18 14:47:20 +13:00
Hazem Noor
2a1a5ae852 Fix: Update command npm run update-caniemail save path (#422) 2025-01-09 10:59:01 +13:00
Ralph Slooten
e30754a167 Fix: Correct date formatting in TestMakeHeaders 2025-01-01 22:49:23 +13:00
Ralph Slooten
fd46d4076b Merge tag 'v1.21.8' into develop
Release v1.21.8
2024-12-20 16:48:13 +13:00
Ralph Slooten
7703d09919 Merge branch 'release/v1.21.8' 2024-12-20 16:47:59 +13:00
Ralph Slooten
b3e7995342 Release v1.21.8 2024-12-20 16:47:58 +13:00
Ralph Slooten
c8937e218f Chore: Update node dependencies 2024-12-20 16:16:43 +13:00
Ralph Slooten
82cfd605e5 Chore: Update Go dependencies 2024-12-20 16:14:47 +13:00
Ralph Slooten
d67feec713 Fix(db): Remove unused FOREIGN KEY REFERENCES in message_tags table (#374)
This SQL patch rebuilds the message_tags table to remove the unused ID & TagID REFERENCES that was sometimes causing FOREIGN KEY errors when deleting messages (with tags) using the rqlite database. This is not a bug in rqlite, but rather a limitation of how Mailpit integrated with rqlite as an optional alternative database.
2024-12-20 16:12:40 +13:00
Thomas Landauer
9f4908d11d Add case-insensitive flags to regex'es (#411)
* Update smtpd.go: Adding case-insensitive flags to regex'es
* Update smtpd_test.go
2024-12-15 07:56:20 +13:00
Ralph Slooten
13027bf10b Merge tag 'v1.21.7' into develop
Release v1.21.7
2024-12-14 23:01:13 +13:00
Ralph Slooten
37c0558ddd Merge branch 'release/v1.21.7' 2024-12-14 23:01:07 +13:00
Ralph Slooten
9d205cfdcc Release v1.21.7 2024-12-14 23:01:05 +13:00
Ralph Slooten
e01a0f8f4b Chore: Update node dependencies 2024-12-14 22:53:35 +13:00
Ralph Slooten
ebf8f3568b Chore: Update Go dependencies 2024-12-14 22:50:53 +13:00
Ralph Slooten
572bda80a2 Chore: Bump Go version for automated testing 2024-12-14 18:03:49 +13:00
Ralph Slooten
23fee8e4e1 Chore: Move smtpd & pop3 modules to internal 2024-12-14 17:51:02 +13:00
Ralph Slooten
b2f4acb7ed Rename smtpd files 2024-12-14 15:12:45 +13:00
Thomas Landauer
2ea92d1b7e Chore: Stricter SMTP 'MAIL FROM' & 'RCPT TO' handling (#409)
* Update lib.go: Changing `mailFromRE` and `rcptToRE` regex

I don't know how to rebase so I started from scratch ;-)

2 changes compared to what you said at https://github.com/axllent/mailpit/pull/406#issuecomment-2540350780
* I did the same for `rcptToRE`
* I replaced the `*` quantifier with `+`, for consistency

* Update lib.go

* Allow valid empty MAIL FROM value

---------

Co-authored-by: Ralph Slooten <axllent@gmail.com>
2024-12-14 15:03:20 +13:00
Ralph Slooten
0af5d184f5 Add SMTP From address when ignored parameters are received 2024-12-13 20:05:30 +13:00
Ralph Slooten
4ad6a4553c Fix: Ignore unsupported optional SMTP 'MAIL FROM' parameters (#407) 2024-12-13 15:40:11 +13:00
dependabot[bot]
b5734691e8 Bump golang.org/x/crypto from 0.30.0 to 0.31.0 (#408)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.30.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-13 07:10:47 +13:00
Ralph Slooten
e78bc79f5e Testing: Add smtpd tests 2024-12-13 06:25:19 +13:00
Ralph Slooten
0fbb9463d4 Minor style change 2024-12-12 08:26:44 +13:00
Ralph Slooten
4c954e655c Chore: Display "To" details in mobile messages list 2024-12-10 22:00:36 +13:00
Ralph Slooten
16fbb728a4 Chore: Display "From" details in message sidebar (desktop) (#403) 2024-12-10 22:00:00 +13:00
Ralph Slooten
b27a28cbf5 Fix: Prevent splitting multi-byte characters in message snippets (#404) 2024-12-10 18:12:35 +13:00
Ralph Slooten
b1c745fb32 Merge tag 'v1.21.6' into develop
Release v1.21.6
2024-12-08 12:59:40 +13:00
Ralph Slooten
ccd35c7dc9 Merge branch 'release/v1.21.6' 2024-12-08 12:59:25 +13:00
Ralph Slooten
11a9014241 Release v1.21.6 2024-12-08 12:59:24 +13:00
Ralph Slooten
93c9eb3fbf Chore: Update caniemail database 2024-12-08 12:55:49 +13:00
Ralph Slooten
68f2a3189e Chore: Update node dependencies 2024-12-08 12:55:09 +13:00
Ralph Slooten
d57aa9b37e Chore: Update Go dependencies 2024-12-08 12:52:38 +13:00
Ralph Slooten
14f1a44c7a Feature: Include Mailpit label (if set) in webhook HTTP header (#400) 2024-12-06 17:21:52 +13:00
Ralph Slooten
3e7d4f8175 Feature: Add support for sending inline attachments via HTTP API (#399)
Optional settings for Attachment ContentID & ContentType
2024-12-05 21:25:59 +13:00
Ralph Slooten
22cae16e00 Fix error handling 2024-12-01 14:49:01 +13:00
avesst
6e44691f6d Fix: Message view not updating when deleting messages from search (#395)
* Fix message view not updating when deleting messages from search

* Move logic to after SQL execution and prune if > 200 messages
2024-11-30 22:54:14 +13:00
Ralph Slooten
aabb2acab9 Merge tag 'v1.21.5' into develop
Release v1.21.5
2024-11-26 22:24:45 +13:00
Ralph Slooten
0277f4e944 Merge branch 'release/v1.21.5' 2024-11-26 22:24:41 +13:00
Ralph Slooten
7c31b3d0c0 Release v1.21.5 2024-11-26 22:24:40 +13:00
Ralph Slooten
c7f3937cb2 Chore: Update caniemail database 2024-11-26 22:17:04 +13:00
Ralph Slooten
5a3448accf Chore: Update node dependencies 2024-11-26 22:16:16 +13:00
Ralph Slooten
53b55ec320 Chore: Update Go dependencies 2024-11-26 22:14:08 +13:00
Ralph Slooten
0ff73b7df6 Format code 2024-11-22 16:55:24 +13:00
Faster IT
5666462f29 Chore: Make symlink detection more specific to contain "sendmail" in the name (#391) 2024-11-22 16:52:20 +13:00
Ralph Slooten
bc23e6336d Merge tag 'v1.21.4' into develop
Release v1.21.4
2024-11-17 17:56:30 +13:00
Ralph Slooten
6d115ceb86 Merge branch 'release/v1.21.4' 2024-11-17 17:56:26 +13:00
Ralph Slooten
a33d0c9d07 Release v1.21.4 2024-11-17 17:56:25 +13:00
Ralph Slooten
f08a959545 Bugfix: Fix external CSS stylesheet loading in HTML preview (#388) 2024-11-17 17:49:15 +13:00
Ralph Slooten
249a02b71a Merge tag 'v1.21.3' into develop
Release v1.21.3
2024-11-16 15:28:12 +13:00
Ralph Slooten
b698e037bf Merge branch 'release/v1.21.3' 2024-11-16 15:28:10 +13:00
Ralph Slooten
8dab8abde4 Release v1.21.3 2024-11-16 15:28:10 +13:00
Ralph Slooten
8c2e5d856a Chore: Update Go dependencies 2024-11-16 15:23:04 +13:00
Ralph Slooten
1afd138cc5 Chore: Minor UI tweaks 2024-11-16 15:21:45 +13:00
Ralph Slooten
c4e0e651a3 Chore: Mute Dart Sass deprecation notices 2024-11-16 14:55:35 +13:00
Ralph Slooten
0b11ce26ab Chore: Update node dependencies 2024-11-16 14:54:43 +13:00
Ralph Slooten
bc7d7f901d Chore: Upgrade Alpine packages on Docker build 2024-11-10 07:57:45 +13:00
Ralph Slooten
a7fac05209 Remove swagger parameter examples (invalid format) 2024-11-09 16:51:23 +13:00
Ralph Slooten
657cada916 Chore: Add swagger examples & API code restructure 2024-11-09 13:24:20 +13:00
Ralph Slooten
ea219e5ec9 Merge branch 'release/v1.21.2' 2024-11-08 23:19:35 +13:00
Ralph Slooten
8f79fcd0d5 Release v1.21.2 2024-11-08 23:19:34 +13:00
Ralph Slooten
61cff513cb Chore: Remove legacy Tags column from message DB table 2024-11-08 23:02:27 +13:00
Ralph Slooten
13caeb4f5b Chore: Update Go dependencies 2024-11-08 22:11:31 +13:00
Ralph Slooten
3f2457cc6a Chore: Update node dependencies 2024-11-08 22:07:11 +13:00
Ralph Slooten
2c94c32722 Feature: Add additional ignored flags to sendmail (#384)
commit 5dc0ac63d414d88c516785cba26c5cec24fc817a
Author: Ralph Slooten <axllent@gmail.com>
Date:   Fri Nov 8 21:58:46 2024 +1300

    Add new ignored flags to sendmail help

commit 810e6ffc2348328bfd8a0a148c95ccfb7a13cbcb
Author: Lucas dos Santos Abreu <lucas.s.abreu@gmail.com>
Date:   Fri Nov 8 05:37:27 2024 -0300

    Feature: Add additional ignored flags to sendmail (#384)
2024-11-08 21:59:20 +13:00
Ralph Slooten
d448211653 Fix: Fix browser notification request on Edge (#89) 2024-11-07 16:35:37 +13:00
Ralph Slooten
ccef1ae20d Merge tag 'v1.21.1' into develop
Release v1.21.1
2024-11-01 22:24:35 +13:00
Ralph Slooten
0f24496ee2 Merge branch 'release/v1.21.1' 2024-11-01 22:24:23 +13:00
Ralph Slooten
2743e2e0cb Release v1.21.1 2024-11-01 22:24:22 +13:00
Ralph Slooten
4be92633f2 Chore: Update Go dependencies 2024-11-01 22:19:23 +13:00
Ralph Slooten
5675abef84 Feature: Add ability to search by size smaller or larger than a value (eg: larger:1M / smaller:2.5M) 2024-10-27 02:15:13 +13:00
Ralph Slooten
bd47c19058 Feature: Add ability to search for messages containing inline images (has:inline) 2024-10-27 02:12:19 +13:00
Ralph Slooten
47c6062b1c Chore: Separate attachments and inline images in download nav and badges (#379) 2024-10-26 23:14:55 +13:00
Ralph Slooten
48a1f6b877 Merge tag 'v1.21.0' into develop
Release v1.21.0
2024-10-24 23:51:54 +13:00
Ralph Slooten
10c20dd00f Merge branch 'release/v1.21.0' 2024-10-24 23:51:49 +13:00
Ralph Slooten
57d32c6627 Release v1.21.0 2024-10-24 23:51:47 +13:00
Ralph Slooten
4ba1343184 Merge branch 'feature/multi-select-macos' into develop 2024-10-24 23:27:44 +13:00
Ralph Slooten
e4da814ece Use consistent @click syntax 2024-10-24 23:27:26 +13:00
Tobi
324a0ac037 Fix: Allow multiple item selection on macOS with Cmd-click (#378)
* UI: Make multiple tag selection work on macOS

* UI: Allow click+meta key combination to select messages in list
2024-10-24 23:22:48 +13:00
Ralph Slooten
e1b02be9ba Merge branch 'feature/unix-sockets' into develop 2024-10-24 23:13:53 +13:00
Ralph Slooten
31ec6681a7 Feature: Experimental Unix socket support for HTTPD & SMTPD (#373) 2024-10-24 23:12:34 +13:00
Ralph Slooten
e2c3256f0c Ignore gosec warning about file permission being set via tar header 2024-10-24 16:52:09 +13:00
Ralph Slooten
2420aa7c2a Merge tag 'v1.20.7' into develop
Release v1.20.7
2024-10-19 23:43:26 +13:00
Ralph Slooten
009d02816f Merge branch 'release/v1.20.7' 2024-10-19 23:43:17 +13:00
Ralph Slooten
5131b6a0cc Release v1.20.7 2024-10-19 23:43:16 +13:00
Ralph Slooten
d2070e1ee1 Chore: Update caniemail database 2024-10-18 18:03:25 +13:00
Ralph Slooten
405babda7b Testing: Add tenantIDs to tests 2024-10-18 17:55:46 +13:00
Ralph Slooten
882adeebe3 SQL error deleting a tag while using tenant-id (take 2) 2024-10-17 22:41:41 +13:00
Ralph Slooten
f8efda0149 Fix: SQL error deleting a tag while using tenant-id (#374) 2024-10-17 22:30:58 +13:00
Ralph Slooten
d61304a854 Merge tag 'v1.20.6' into develop
Release v1.20.6
2024-10-14 17:42:20 +13:00
Ralph Slooten
4ff9fdf298 Merge branch 'release/v1.20.6' 2024-10-14 17:42:17 +13:00
Ralph Slooten
51e29ba90a Release v1.20.6 2024-10-14 17:42:17 +13:00
Ralph Slooten
9ab289a6c9 Chore: Bump Go compile version to 1.23 2024-10-14 17:39:52 +13:00
Ralph Slooten
2c945be5b9 Remove blank authentication from RapiDoc 2024-10-12 15:54:08 +13:00
Ralph Slooten
f9a185da46 Chore: Update node modules 2024-10-12 15:50:26 +13:00
Ralph Slooten
73a993492e Chore: Update swagger file tests 2024-10-12 15:30:33 +13:00
Ralph Slooten
a56fd1f53d Chore: Code cleanup 2024-10-12 15:20:11 +13:00
Ralph Slooten
073ddd33d5 Update stale issue action 2024-10-02 17:43:10 +02:00
Ralph Slooten
f142893d58 Chore: Update Go dependencies 2024-10-01 11:50:57 +02:00
Ralph Slooten
bd026bef8c Chore: Update minimum Go version (1.22.0) 2024-10-01 11:48:20 +02:00
Ralph Slooten
26e8706eb4 Chore: Update node dependencies 2024-10-01 11:40:41 +02:00
Ralph Slooten
ff8cd229ca Merge tag 'v1.20.5' into develop
Release v1.20.5
2024-09-26 17:20:28 +02:00
Ralph Slooten
2c326acf08 Merge branch 'release/v1.20.5' 2024-09-26 17:20:25 +02:00
Ralph Slooten
1aed5fda5a Release v1.20.5 2024-09-26 17:20:25 +02:00
Ralph Slooten
9a4982e646 Chore: Update node modules 2024-09-26 17:13:16 +02:00
cui fliter
a64950ddea Fix: Use correct parameter order in SpamAssassin socket detection (#364)
Signed-off-by: cuishuang <imcusg@gmail.com>
2024-09-27 03:04:52 +12:00
Ralph Slooten
7f4cd90c03 Add undocumented "demonstration mode" 2024-09-08 00:23:15 +12:00
Ralph Slooten
56f1138f8e Chore: Use consistent margins for Mailpit label if set 2024-09-07 17:34:42 +12:00
Ralph Slooten
bd5c450294 Chore: Improve tag detection in UI 2024-09-06 15:57:41 +12:00
Ralph Slooten
54a72e8e1e Chore: Improve link detection in the HTML preview 2024-09-05 17:46:02 +12:00
Ralph Slooten
069967f502 Merge tag 'v1.20.4' into develop
Release v1.20.4
2024-09-05 17:33:21 +12:00
Ralph Slooten
4ee3ba4753 Merge branch 'release/v1.20.4' 2024-09-05 17:33:19 +12:00
Ralph Slooten
84e46e6604 Release v1.20.4 2024-09-05 17:33:19 +12:00
Ralph Slooten
2048f15bbf Chore: Update Go modules 2024-09-05 17:32:03 +12:00
Ralph Slooten
93761b6f53 Chore: Update node modules 2024-09-05 17:31:05 +12:00
Ralph Slooten
2a0853d21a Fix: Relax URL detection in link check tool (#357) 2024-09-05 17:15:53 +12:00
Ralph Slooten
dc1a16ed5c Chore: Upgrade vue-css-donut-chart & related charts 2024-09-01 22:08:18 +12:00
Ralph Slooten
f95147fd83 Merge tag 'v1.20.3' into develop
Release v1.20.3
2024-09-01 19:56:00 +12:00
Ralph Slooten
c84bfc3330 Merge branch 'release/v1.20.3' 2024-09-01 19:55:56 +12:00
Ralph Slooten
b22eccd88c Release v1.20.3 2024-09-01 19:55:54 +12:00
Ralph Slooten
1c8f0bf136 Chore: Update caniemail database 2024-09-01 19:52:44 +12:00
Ralph Slooten
48195b004e Chore: Update node dependencies 2024-09-01 19:51:07 +12:00
Ralph Slooten
32185e3abe Chore: Update Go dependencies 2024-09-01 19:18:33 +12:00
Ralph Slooten
be1d2bcb28 Fix: Disable automatic HTML/Text character detection when charset is provided (#348) 2024-09-01 18:35:42 +12:00
Ralph Slooten
259d71122b Chore: Do not recenter selected messages in sidebar on every new message 2024-09-01 08:56:45 +12:00
Ralph Slooten
b37a24fdcf Code cleanup 2024-08-25 00:01:17 +12:00
Ralph Slooten
f598c9adbb Merge tag 'v1.20.2' into develop
Release v1.20.2
2024-08-17 23:12:57 +12:00
Ralph Slooten
aaa873ed68 Merge branch 'release/v1.20.2' 2024-08-17 23:12:54 +12:00
Ralph Slooten
fb8b24cc28 Release v1.20.2 2024-08-17 23:12:53 +12:00
Ralph Slooten
7d55e20e85 Chore: Update Go dependencies 2024-08-17 23:09:43 +12:00
Ralph Slooten
e98109a238 Chore: Update node dependencies 2024-08-17 23:07:12 +12:00
Ralph Slooten
3cec8bfab8 Merge branch 'feature/smtpd-debug' into develop 2024-08-17 23:03:27 +12:00
Ralph Slooten
4f2324a367 Feature: Web UI notifications of smtpd & POP3 errors (#347) 2024-08-17 23:02:55 +12:00
Ralph Slooten
ac60ed62ae Update smtpd logging format 2024-08-17 23:02:54 +12:00
Ralph Slooten
65327b975b Chore: Add debug database storage logging 2024-08-17 23:02:48 +12:00
Ralph Slooten
ba42cac2ad Chore: Add smtpd server logging in the CLI (#347) 2024-08-17 14:15:53 +12:00
Ralph Slooten
5fc025b1a5 Remove negative margin of tags button 2024-08-10 12:28:00 +12:00
Ralph Slooten
48bef8d7ac Merge tag 'v1.20.1' into develop
Release v1.20.1
2024-08-10 12:07:16 +12:00
Ralph Slooten
37ea30fcdb Merge branch 'release/v1.20.1' 2024-08-10 12:07:13 +12:00
Ralph Slooten
8f1b804b2a Release v1.20.1 2024-08-10 12:07:13 +12:00
Ralph Slooten
f8a6bd7d5e Chore: Shift inbox pagination to inbox component 2024-08-10 11:41:33 +12:00
Ralph Slooten
047c658157 Chore: Live load up to 100 new messages in sidebar (#336) 2024-08-10 11:13:54 +12:00
Ralph Slooten
a060abd5fe Fix: Correctly decode X-Tags message headers (RFC 2047) (#344) 2024-08-09 14:26:43 +12:00
Ralph Slooten
a21808df65 Chore: Show icon attachment in new side navigation message listing (#345) 2024-08-09 13:54:05 +12:00
Ralph Slooten
1e4fc9f003 Merge tag 'v1.20.0' into develop
Release v1.20.0
2024-08-06 18:58:20 +12:00
Ralph Slooten
3fdbcaff8a Merge branch 'release/v1.20.0' 2024-08-06 18:58:12 +12:00
Ralph Slooten
71820dc124 Release v1.20.0 2024-08-06 18:58:10 +12:00
Ralph Slooten
81e98d1376 Various UI tweaks 2024-08-06 17:38:42 +12:00
Ralph Slooten
27c36f52b2 Cleanup redundant code 2024-08-06 17:31:40 +12:00
Ralph Slooten
325394876d Chore: Update caniemail database 2024-08-06 17:26:10 +12:00
Ralph Slooten
5a54994a5d Chore: Update Go dependencies 2024-08-06 17:25:07 +12:00
Ralph Slooten
d48b5e8674 Feature: Add option to control message retention by age (#338) 2024-08-06 17:23:28 +12:00
Ralph Slooten
3f3da220cf Chore: Update node dependencies 2024-08-04 17:16:10 +12:00
Ralph Slooten
9040e04edf Merge branch 'feature/sidebar-email-list' into develop 2024-08-04 17:11:26 +12:00
Ralph Slooten
6baf13b25b Fix: Prevent potential JavaScript errors caused by race condition 2024-08-04 17:10:28 +12:00
Ralph Slooten
4716c18d5f Fix: Better regexp to detect tags in search 2024-08-04 17:07:53 +12:00
Ralph Slooten
22693f727f Add websocket delay to prevent joining messages 2024-08-04 17:06:55 +12:00
Ralph Slooten
476843d9f3 Chore: Make internal tagging methods private 2024-08-04 17:05:58 +12:00
Ralph Slooten
a1cb0af639 Feature(UI): List messages in side nav when viewing message for easy navigation (#336) 2024-08-04 17:04:14 +12:00
Ralph Slooten
54e0c32948 Fix(API): Return text/plain header for message delete request 2024-08-02 16:11:03 +12:00
Ralph Slooten
9670183d0f Fix: Prevent Vue race condition to initialize dayjs relativeTime plugin 2024-07-28 10:59:02 +12:00
Ralph Slooten
05da2a76f4 Merge tag 'v1.19.3' into develop
Release v1.19.3
2024-07-26 22:17:20 +12:00
Ralph Slooten
f16289078e Merge branch 'release/v1.19.3' 2024-07-26 22:17:16 +12:00
Ralph Slooten
5580967c78 Release v1.19.3 2024-07-26 22:17:15 +12:00
Ralph Slooten
eeb2c03424 Chore: Update Go dependencies 2024-07-26 22:09:41 +12:00
Ralph Slooten
0127b9a1f2 Merge branch 'feature/stored-xss' into develop 2024-07-26 22:06:14 +12:00
Ralph Slooten
a078c318e8 Fix(Security): Prevent bypass of Contend Security Policy using stored XSS, and sanitize preview HTML data (DOMPurify)
This closes a security hole whereby a bad actor with SMTP access can bypass the CSP headers with a series of specially crafted HTML messages. A special thanks to @bmodotdev for responsibly disclosing the vulnerability and proving information and an initial fix.
2024-07-26 22:02:14 +12:00
Ralph Slooten
9e881ea868 Chore: Display nicer noscript message when JavaScript is disabled 2024-07-24 19:19:26 +12:00
Ralph Slooten
41c957b807 Add security policy 2024-07-23 17:23:56 +12:00
Ralph Slooten
ea0b5f66f7 Merge tag 'v1.19.2' into develop
Release v1.19.2
2024-07-21 16:11:55 +12:00
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
Ralph Slooten
da83ebbf47 Merge branch 'release/v1.15.0' 2024-03-17 15:06:20 +13:00
Ralph Slooten
b55fd26906 Release v1.15.0 2024-03-17 15:06:19 +13:00
Ralph Slooten
be3729c891 Chore: Update node dependencies 2024-03-17 15:02:23 +13:00
Ralph Slooten
bdb1b9e053 Chore: Update Go dependencies 2024-03-17 15:02:15 +13:00
Ralph Slooten
e70cb75d4a Merge branch 'feature/smtp-tls' into develop 2024-03-17 14:59:34 +13:00
Ralph Slooten
5c1dfe5e26 Update README 2024-03-17 14:59:24 +13:00
Ralph Slooten
73446ed6f7 Fix: Enforce SMTP STARTTLS by default if authentication is set 2024-03-17 14:59:14 +13:00
Ralph Slooten
528c35eec6 Feature: Add SMTP TLS option (#265) 2024-03-17 14:57:41 +13:00
Ralph Slooten
7071e7c188 Merge tag 'v1.14.4' into develop
Release v1.14.4
2024-03-12 17:18:39 +13:00
Ralph Slooten
fb2fe099b1 Merge branch 'release/v1.14.4' 2024-03-12 17:18:37 +13:00
Ralph Slooten
6879afb4a0 Release v1.14.4 2024-03-12 17:18:37 +13:00
Ralph Slooten
edc529fbde Chore: Update caniemail test data 2024-03-12 17:11:43 +13:00
Ralph Slooten
a324d817b3 Feature: Allow setting SMTP relay configuration values via environment variables (#262) 2024-03-12 17:10:13 +13:00
Ralph Slooten
053779c656 Chore: Reorder CLI flags to group by related functionality 2024-03-12 17:10:07 +13:00
Ralph Slooten
ddf2227397 Merge branch 'release/v1.14.3' 2024-03-10 18:47:08 +13:00
Ralph Slooten
bd892e3a48 Release v1.14.3 2024-03-10 18:47:08 +13:00
Ralph Slooten
b6454c902c Chore: Update node dependencies 2024-03-10 18:45:49 +13:00
Ralph Slooten
25f8a47c73 Chore: Update Go dependencies 2024-03-10 18:43:50 +13:00
Ralph Slooten
28710d0462 Fix: Prevent crash when calculating deleted space percentage (divide by zero) 2024-03-10 18:41:27 +13:00
Ralph Slooten
cf18f529f4 Merge tag 'v1.14.2' into develop
Release v1.14.2
2024-03-10 08:11:41 +13:00
Ralph Slooten
c1b03212d5 Merge branch 'release/v1.14.2' 2024-03-10 08:11:35 +13:00
Ralph Slooten
026d676901 Release v1.14.2 2024-03-10 08:11:31 +13:00
Ralph Slooten
e660d6bedd Chore: Allow setting of multiple message tags via plus addresses (#253) 2024-03-10 08:05:11 +13:00
Ralph Slooten
d1d0ce4737 Fix: Prevent runtime error when calculating total messages size of empty table (#263) 2024-03-10 07:48:44 +13:00
Ralph Slooten
bdea197a0f Merge tag 'v1.14.1' into develop
Release v1.14.1
2024-03-02 23:12:34 +13:00
Ralph Slooten
9c9530081c Merge branch 'release/v1.14.1' 2024-03-02 23:12:31 +13:00
Ralph Slooten
ed8cac2454 Release v1.14.1 2024-03-02 23:12:29 +13:00
Ralph Slooten
3bbed37907 Add edge Docker hash 2024-03-02 23:02:47 +13:00
Ralph Slooten
4fa8014735 Fix: Handle null values in Mailpit settings, set DeletedSize=0 if null 2024-03-02 22:51:30 +13:00
Ralph Slooten
23b1261cf9 Chore: Tag names now allow . and must be a minimum of 1 character 2024-03-02 22:51:30 +13:00
Ralph Slooten
85473762c5 Update go-release-action 2024-03-02 22:51:29 +13:00
Ralph Slooten
f076d52603 Chore: Update node dependencies 2024-03-02 22:51:29 +13:00
Ralph Slooten
cf93f99cc2 Chore: Update Go dependencies 2024-03-02 22:51:28 +13:00
Ralph Slooten
0f725ef1d8 Feature: Option to enforce TitleCasing for all newly created tags 2024-03-01 17:22:13 +13:00
Ralph Slooten
0353520aeb Feature: Set message tags using plus addressing (#253) 2024-03-01 17:21:21 +13:00
Ralph Slooten
bfd5837710 Update README 2024-02-24 23:47:03 +13:00
Ralph Slooten
321bc338e6 Merge tag 'v1.14.0' into develop
Release v1.14.0
2024-02-24 23:27:04 +13:00
Ralph Slooten
75a6cfb31c Merge branch 'release/v1.14.0' 2024-02-24 23:26:59 +13:00
Ralph Slooten
7cb71ad5bf Release v1.14.0 2024-02-24 23:26:57 +13:00
Ralph Slooten
9892375366 Chore: Update node dependencies 2024-02-24 23:20:27 +13:00
Ralph Slooten
e55d4aab59 Chore: Update Go dependencies 2024-02-24 23:16:04 +13:00
Ralph Slooten
d521eca2d1 Merge branch 'feature/pop3' into develop 2024-02-24 23:11:38 +13:00
Ralph Slooten
e8c306b7ab Update README 2024-02-24 23:10:58 +13:00
Ralph Slooten
f548bbb874 Feature: Optional POP3 server (#249)
Originally requested in #72
2024-02-24 23:10:48 +13:00
Ralph Slooten
f067b76c58 Update cron logic 2024-02-17 23:19:32 +13:00
Ralph Slooten
5458b1044f Docker: Add edge Docker images for latest unreleased features 2024-02-17 22:48:59 +13:00
Ralph Slooten
294f9a21e6 Chore: Refactor storage library 2024-02-17 22:36:32 +13:00
Ralph Slooten
26a2095674 Chore: Security improvements (gosec) 2024-02-17 12:38:30 +13:00
Ralph Slooten
b2a0d73572 Chore: Switch to short uuid format for database IDs 2024-02-17 11:48:42 +13:00
Ralph Slooten
400d5a36c1 Chore: Better handling of automatic database compression (vacuuming) after deleting messages 2024-02-17 11:12:37 +13:00
Ralph Slooten
9861bf96e1 Merge tag 'v1.13.3' into develop
Release v1.13.3
2024-02-09 23:22:13 +13:00
Ralph Slooten
e410fd42dc Merge branch 'release/v1.13.3' 2024-02-09 23:21:58 +13:00
Ralph Slooten
d049cb627f Release v1.13.3 2024-02-09 23:21:57 +13:00
Ralph Slooten
a70d9abdf2 Chore: Update node dependencies 2024-02-09 23:14:32 +13:00
Ralph Slooten
d75efb8181 Chore: Update Go dependencies 2024-02-09 23:11:45 +13:00
Ralph Slooten
a856ce0cfa Merge branch 'feature/reply-to' into develop 2024-02-09 23:09:46 +13:00
Ralph Slooten
5d9aba726e Feature: Add reply-to:<search> search filter (#247) 2024-02-09 23:09:14 +13:00
Ralph Slooten
667218b30b API: Include Reply-To information in message summaries for message list & websocket events 2024-02-09 23:08:34 +13:00
Ralph Slooten
522733f537 Chore: Compress database only when >= 1% of total message size has been deleted 2024-02-05 23:56:10 +13:00
Ralph Slooten
848ce11a69 Chore: Update "About" modal layout when new version is available 2024-02-05 22:55:49 +13:00
Ralph Slooten
2d44159ecc Merge tag 'v1.13.2' into develop
Release v1.13.2
2024-02-05 22:33:50 +13:00
Ralph Slooten
b3ae4188fe Merge branch 'release/v1.13.2' 2024-02-05 22:33:47 +13:00
Ralph Slooten
3e241a8c20 Release v1.13.2 2024-02-05 22:33:46 +13:00
Ralph Slooten
b4003f6899 Chore: Update caniemail data 2024-02-05 22:27:34 +13:00
Ralph Slooten
44fb691971 Chore: Update node modules 2024-02-05 22:25:55 +13:00
Ralph Slooten
ee301c79fb Chore: Update Go modules 2024-02-05 22:23:16 +13:00
Ralph Slooten
7318c5ca4a Feature: Add option to log output to file (#246) 2024-02-05 22:20:57 +13:00
Ralph Slooten
10021e7a92 Chore: Bump actions build requirement versions 2024-02-01 20:58:19 +13:00
Ralph Slooten
41160fe5bb Chore: Update esbuild 2024-02-01 20:54:15 +13:00
Ralph Slooten
0454840da1 Merge tag 'v1.13.1' into develop
Release v1.13.1
2024-01-27 23:14:28 +13:00
Ralph Slooten
e812d12590 Merge branch 'release/v1.13.1' 2024-01-27 23:14:17 +13:00
Ralph Slooten
0bff5fa0c2 Release v1.13.1 2024-01-27 23:14:16 +13:00
Ralph Slooten
c1dd84fd77 Chore: Update node dependencies 2024-01-27 23:08:33 +13:00
Ralph Slooten
6777e7737f Chore: Update Go dependencies 2024-01-27 23:04:08 +13:00
Ralph Slooten
dda0b0c8a6 Feature: Add TLSRequired option for smtpd (#241) 2024-01-27 23:00:07 +13:00
Ralph Slooten
c256b91de7 Fix search casing 2024-01-25 22:19:32 +13:00
Ralph Slooten
2ad458002c Fix: Workaround for specific field searches containing unicode characters (#239)
The LIKE operator is case sensitive by default in SQLIte for unicode characters (outside of the ASCII range). This workaround assumes the searched unicode character matches the case of the field. General searches are not affected by this as everything is lowercased.
2024-01-25 20:25:56 +13:00
Ralph Slooten
f4f6a9b217 Fix error typo 2024-01-23 16:13:53 +13:00
Ralph Slooten
193f38d063 Update swagger docs 2024-01-23 16:13:03 +13:00
Ralph Slooten
a31672b6f3 UI: Only show number of messages ignored statistics if --ignore-duplicate-ids is set 2024-01-23 16:11:11 +13:00
Ralph Slooten
5271f5226b Merge tag 'v1.13.0' into develop
Release v1.13.0
2024-01-21 14:32:20 +13:00
Ralph Slooten
7f31fb716a Merge branch 'release/v1.13.0' 2024-01-21 14:32:15 +13:00
Ralph Slooten
320a2024a4 Release v1.13.0 2024-01-21 14:32:13 +13:00
Ralph Slooten
6e4b7b3a15 Merge branch 'feature/rdns' into develop 2024-01-21 14:24:00 +13:00
Ralph Slooten
b21f1d422e Update Go modules 2024-01-21 14:23:51 +13:00
Ralph Slooten
9816c80c59 Chore: Compress compiled assets with npm run build 2024-01-21 14:22:17 +13:00
Ralph Slooten
d212063d22 Update Node modules 2024-01-21 14:19:11 +13:00
Ralph Slooten
6725db4fa5 Feature: Add option to disable SMTP reverse DNS (rDNS) lookup (#230) 2024-01-21 09:05:08 +13:00
Ralph Slooten
3f98ac5087 Update README 2024-01-21 07:47:09 +13:00
Ralph Slooten
76c2350d03 Chore: Update Go modules 2024-01-21 07:46:32 +13:00
Ralph Slooten
d32600e910 Chore: Update node modules 2024-01-21 07:45:47 +13:00
Ralph Slooten
35a4c5e13f Merge branch 'feature/list-unsubscribe' into develop 2024-01-20 23:06:16 +13:00
Ralph Slooten
0261f87faf Remove unused imports 2024-01-20 23:06:02 +13:00
Ralph Slooten
98a15e5918 Feature: Display List-Unsubscribe & List-Unsubscribe-Post header info with syntax validation (#236) 2024-01-20 23:05:28 +13:00
Ralph Slooten
128796d4ca Fix: Display multiple whitespace characters in message subject & recipient names (#238) 2024-01-20 12:29:28 +13:00
Ralph Slooten
9cda71f21a Feature: Add optional SpamAssassin integration to display scores (#233) 2024-01-20 12:07:49 +13:00
Ralph Slooten
9a63567b0c Fix: Sendmail support for -f 'Name <email@example.com>' format 2024-01-03 15:46:57 +13:00
Ralph Slooten
cb667eabee Merge tag 'v1.12.1' into develop
Release v1.12.1
2024-01-03 15:03:17 +13:00
Ralph Slooten
fa8b398afc Merge branch 'release/v1.12.1' 2024-01-03 15:03:16 +13:00
Ralph Slooten
b8385dc18b Release v1.12.1 2024-01-03 15:03:15 +13:00
Ralph Slooten
0c3519cb0d Limit testing for web UI build & swagger-editor-validate to Ubuntu 2024-01-03 14:58:35 +13:00
Ralph Slooten
8c86cc624e Limit testing for web UI build & swagger-editor-validate to Ubuntu 2024-01-03 14:52:53 +13:00
Ralph Slooten
4d2b6d6b4a Tests: Run tests on Linux, Windows & Mac 2024-01-03 14:41:52 +13:00
Ralph Slooten
669c1a747f Chore: Significantly increase database performance using WAL (Write-Ahead-Log) 2024-01-03 14:39:28 +13:00
Ralph Slooten
119e6a55d2 Fix: Log total deleted messages when auto-pruning messages (--max) 2024-01-03 13:13:43 +13:00
Ralph Slooten
381813fe63 Fix: Prevent rare error from websocket connection (unexpected non-whitespace character) 2024-01-03 13:09:06 +13:00
Ralph Slooten
dd57596fd1 UI: Automatically refresh connected browsers if Mailpit is upgraded (version change) 2024-01-03 12:54:12 +13:00
Ralph Slooten
12cfb09774 Update swagger docs 2024-01-03 12:30:15 +13:00
Ralph Slooten
a25c7e359a Libs: Update node modules 2024-01-03 12:24:33 +13:00
Ralph Slooten
d705571cb5 Merge branch 'feature/smtp-allowed-recipients' into develop 2024-01-03 12:21:30 +13:00
Ralph Slooten
f4c703b686 Chore: Standardize error logging & formatting 2024-01-03 12:21:00 +13:00
Ralph Slooten
cdab59b295 Feature: Add option to only allow SMTP recipients matching a regular expression (disable open-relay behaviour #219) 2024-01-03 12:06:36 +13:00
Ralph Slooten
aad15945b3 Fix: Log total deleted messages when deleting all messages from search 2024-01-02 23:43:35 +13:00
Ralph Slooten
761cd2cd2e Merge tag 'v1.12.0' into develop
Release v1.12.0
2024-01-02 20:06:02 +13:00
Ralph Slooten
7658fd8157 Merge branch 'release/v1.12.0' 2024-01-02 20:05:57 +13:00
Ralph Slooten
2086d0f114 Release v1.12.0 2024-01-02 20:05:54 +13:00
Ralph Slooten
8774b57a61 Fix formatting 2024-01-02 19:01:50 +13:00
Ralph Slooten
d8034b66d1 Update README 2024-01-02 19:00:47 +13:00
Ralph Slooten
4ecb70d60d Update README 2024-01-02 18:53:02 +13:00
Ralph Slooten
42dcb05b8a Update README 2024-01-02 18:51:37 +13:00
Ralph Slooten
6aa23d987a Remove ineffectual assignment of values 2024-01-02 17:29:59 +13:00
Ralph Slooten
857df79dd5 Update function comment 2024-01-02 17:19:08 +13:00
Ralph Slooten
8f3a5e1fba Remove outdated documentation 2024-01-02 17:18:38 +13:00
Ralph Slooten
f787df2c8b Merge branch 'feature/stats' into develop 2024-01-02 13:23:54 +13:00
Ralph Slooten
0af11fcb28 Chore: Include runtime statistics in API (info) & UI (About)
Resolves #218
2024-01-02 13:23:16 +13:00
Ralph Slooten
e0dc3726bc Chore: Use memory pointer for internal message parsing & storage 2024-01-02 13:14:21 +13:00
Ralph Slooten
bf181eaad5 Chore: Update caniemail test data 2024-01-02 00:24:23 +13:00
Ralph Slooten
38a260a4eb Update swagger json 2024-01-02 00:22:30 +13:00
dependabot[bot]
69646d06c5 Bump wangyoucao577/go-release-action from 1.40 to 1.41 (#226)
Bumps [wangyoucao577/go-release-action](https://github.com/wangyoucao577/go-release-action) from 1.40 to 1.41.
- [Release notes](https://github.com/wangyoucao577/go-release-action/releases)
- [Commits](https://github.com/wangyoucao577/go-release-action/compare/v1.40...v1.41)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 23:56:31 +13:00
Ralph Slooten
c2d76b1edd Libs: Update node modules 2024-01-01 23:54:52 +13:00
Ralph Slooten
b3c82976b1 Libs: Update Go modules 2024-01-01 23:50:55 +13:00
Ralph Slooten
c70d101d7b Merge branch 'feature/tags' into develop 2024-01-01 23:47:15 +13:00
Ralph Slooten
06ca217cde Chore: Convert to many-to-many message tag relationships 2024-01-01 23:46:34 +13:00
Ralph Slooten
e032d27ef6 Standardize error logging & formatting 2024-01-01 23:43:19 +13:00
dependabot[bot]
5807747fa5 Bump actions/setup-go from 4 to 5 (#225)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 21:38:41 +13:00
dependabot[bot]
c316132102 Bump github/codeql-action from 2 to 3 (#227)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 21:38:04 +13:00
dependabot[bot]
79807586be Bump actions/stale from 8.0.0 to 9.0.0 (#228)
Bumps [actions/stale](https://github.com/actions/stale) from 8.0.0 to 9.0.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v8.0.0...v9.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 21:37:40 +13:00
Ralph Slooten
83e291208a Chore: Standardize error logging & formatting 2024-01-01 15:25:38 +13:00
Ralph Slooten
4568b95bd6 UI: Refresh search results when search resubmitted or active tag filter clicked 2023-12-31 09:22:33 +13:00
Ralph Slooten
0f0717786e Bump golang.org/x/crypto v0.16.0 => v0.17.0 2023-12-19 15:25:56 +13:00
Ralph Slooten
9bfd93b295 Merge tag 'v1.11.1' into develop
Release v1.11.1
2023-12-17 10:49:02 +13:00
Ralph Slooten
c0e939f99b Merge branch 'release/v1.11.1' 2023-12-17 10:48:59 +13:00
Ralph Slooten
c0be3da5bf Release v1.11.1 2023-12-17 10:48:57 +13:00
Ralph Slooten
5f22d33e74 Libs: Update node modules 2023-12-17 10:36:31 +13:00
Ralph Slooten
a522d21bb4 Libs: Update Go modules 2023-12-17 10:34:24 +13:00
Ralph Slooten
262b77b0fe Testing: Add new ingest subcommand to import an email file or maildir folder over SMTP 2023-12-17 10:12:36 +13:00
Ralph Slooten
a32978d149 Fix: Fix regression to support for search query params to all /latest endpoints (#206) 2023-12-17 10:12:35 +13:00
Ralph Slooten
0808e4543f UI: Allow multiple tags to be searched using Ctrl-click (#216) 2023-12-17 10:12:35 +13:00
Ralph Slooten
a8d5887f4f Merge branch 'release/v1.11.0' 2023-12-14 16:27:08 +13:00
Ralph Slooten
bc75701abd Release v1.11.0 2023-12-14 16:27:08 +13:00
Ralph Slooten
176d026fcc Libs: Update node modules 2023-12-14 16:26:12 +13:00
Ralph Slooten
fe82df6f30 Libs: Update Go modules 2023-12-14 16:26:12 +13:00
Ralph Slooten
085e76f33e Feature: Add configuration option to set maximum SMTP recipients (#205) 2023-12-14 16:26:12 +13:00
Ralph Slooten
f69106a67a Deprecate environment MP_STRICT_RFC_HEADERS => MP_SMTP_STRICT_RFC_HEADERS 2023-12-14 16:26:12 +13:00
Ralph Slooten
28cd1fceee API: Allow ID "latest" for message summary, headers, raw version & HTML/link checks 2023-12-14 16:26:03 +13:00
Ralph Slooten
2b6e5fe320 Merge tag 'v1.10.4' into develop
Release v1.10.4
2023-12-07 17:18:54 +13:00
Ralph Slooten
37e36aaeb6 Merge branch 'release/v1.10.4' 2023-12-07 17:18:51 +13:00
Ralph Slooten
b1c45e1eff Release v1.10.4 2023-12-07 17:18:51 +13:00
Ralph Slooten
701741a723 Fix: Remove JS debug information for favicon 2023-12-07 17:17:56 +13:00
Ralph Slooten
b7d7be64fb Merge tag 'v1.10.3' into develop
Release v1.10.3
2023-12-07 16:47:29 +13:00
Ralph Slooten
a4582cec4b Merge branch 'release/v1.10.3' 2023-12-07 16:47:26 +13:00
Ralph Slooten
a4b7552be2 Release v1.10.3 2023-12-07 16:47:26 +13:00
Ralph Slooten
45b148ecc8 Libs: Update node modules 2023-12-07 16:33:08 +13:00
Ralph Slooten
0a60ec3f3d Libs: Update Go modules 2023-12-07 16:29:35 +13:00
Imanuel Ulbricht
4a12f2cd62 Feature: Add @ as valid character for webroot (#215)
Added `@` as valid character for `--webroot`. This allows the usage in Coder without a subdomain.
2023-12-07 16:26:14 +13:00
Ralph Slooten
64483e5ce3 Chore: Update caniemail library & add hr element test 2023-12-04 21:33:15 +13:00
Ralph Slooten
5365313f9a Fix: New favicon notification badge to fix rendering issues (#210) 2023-12-04 21:32:59 +13:00
Ralph Slooten
3a35ded5bf Merge tag 'v1.10.2' into develop
Release v1.10.2
2023-12-01 15:34:46 +13:00
Ralph Slooten
ee39f33f84 Merge branch 'release/v1.10.2' 2023-12-01 15:34:45 +13:00
Ralph Slooten
8e9476e3df Release v1.10.2 2023-12-01 15:34:44 +13:00
Ralph Slooten
ceb4c03dc3 UI: Enable tag colors by default 2023-12-01 15:30:14 +13:00
Ralph Slooten
1c565dc564 Libs: Update node modules 2023-12-01 15:23:19 +13:00
Ralph Slooten
f2c517f892 Libs: Update Go modules 2023-12-01 15:18:40 +13:00
Ralph Slooten
97f1530c89 Chore: Add favicon fallback font (sans-serif) for unread count
See #210
2023-12-01 15:14:36 +13:00
Ralph Slooten
945da2c75c Chore: Clearer log messages for bound SMTP & HTTP addresses
See #211
2023-12-01 15:03:01 +13:00
Ralph Slooten
2e9d5008c2 Feature: Allow port binding using hostname
See #213
2023-12-01 14:50:03 +13:00
Ralph Slooten
cfcb4f0c97 Merge tag 'v1.10.1' into develop
Release v1.10.1
2023-11-19 15:14:11 +13:00
Ralph Slooten
b1c9fb6cf6 Merge branch 'release/v1.10.1' 2023-11-19 15:14:08 +13:00
Ralph Slooten
daac2fc921 Release v1.10.1 2023-11-19 15:14:07 +13:00
Ralph Slooten
359573c231 Libs: Update node modules 2023-11-19 15:10:24 +13:00
Ralph Slooten
13c72e4fe5 Libs: Update Go modules 2023-11-19 15:10:23 +13:00
Ralph Slooten
ad91c10744 Swagger: Revert BinaryResponse type to string
Go-swagger does not appear to support `"format": "binary"`, and `"$ref": "#/definitions/File"` doesn't seem to be supported. Resolves #188
2023-11-19 15:10:23 +13:00
Ralph Slooten
d013158ac3 Fix: Prevent JavaScript error if message is missing From header (#209) 2023-11-19 15:09:54 +13:00
Ralph Slooten
ef41de06ae Chore: Use NextReader() instead of ReadMessage() for websocket reading (#207)
This prevents against malicious buffer overflows.
2023-11-19 15:09:53 +13:00
Ralph Slooten
e80c230120 Merge branch 'release/v1.10.0' 2023-11-12 00:00:36 +13:00
Ralph Slooten
79dad9920a Release v1.10.0 2023-11-12 00:00:35 +13:00
Ralph Slooten
7244f4e2ff Libs: Update node modules 2023-11-11 23:54:42 +13:00
Ralph Slooten
ab8466ff7e Libs: Update Go modules 2023-11-11 23:51:34 +13:00
Ralph Slooten
a5bec762d4 Feature: Support search query params to /latest endpoints (#206) 2023-11-11 23:48:45 +13:00
Ralph Slooten
4c5b024eca Feature: Option to allow untrusted HTTPS certificates for screenshots & link checking (#204) 2023-11-11 23:10:43 +13:00
Ralph Slooten
74236258db Fix: Correctly close websockets on client disconnect (#207) 2023-11-11 15:32:57 +13:00
Ralph Slooten
ffe6167d96 Feature: Add URL redirect (/view/latest) to view latest message in web UI (#166) 2023-11-02 16:15:45 +13:00
dependabot[bot]
baa9f3be0b Bump actions/setup-node from 3 to 4 (#203)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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:

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/mailpit

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

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

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

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

45
.github/workflows/build-docker-edge.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
on:
push:
branches: [ develop ]
name: Build docker edge images
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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@v6
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge
ghcr.io/${{ github.repository }}:edge

53
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
on:
release:
types: [created]
name: Build docker images
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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
with:
input_string: '${{ github.ref_name }}'
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ github.ref_name }}"
push: true
tags: |
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 }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest

View File

@@ -0,0 +1,23 @@
name: Close stale issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9.1.0
with:
days-before-issue-stale: 7
days-before-issue-close: 3
exempt-issue-labels: "enhancement,bug,awaiting feedback"
stale-issue-label: "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 }}

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

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

View File

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

View File

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

49
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Tests
on:
pull_request:
branches: [ develop, 'feature/**' ]
push:
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
matrix:
go-version: ['1.23']
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: false
- uses: actions/checkout@v4
- name: Run Go tests
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets
- name: Build web UI
if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- if: startsWith(matrix.os, 'ubuntu') == true
run: npm install
- if: startsWith(matrix.os, 'ubuntu') == true
run: npm run package
# validate the swagger file
- name: Validate OpenAPI definition
if: startsWith(matrix.os, 'ubuntu') == true
uses: swaggerexpert/swagger-editor-validate@v1
with:
definition-file: server/ui/api/v1/swagger.json

5
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM golang:alpine AS builder
ARG VERSION=dev
COPY . /app
WORKDIR /app
RUN apk upgrade && apk add git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
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 upgrade --no-cache && apk add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
ENTRYPOINT ["/mailpit"]

View File

@@ -1,45 +0,0 @@
# Building Mailpit from source
Go (>= version 1.8) and npm are required to compile mailpit from source.
```
git clone git@github.com:axllent/mailpit.git
cd mailpit
```
## Building the UI
The Mailpit web user interface is built with node. In the project's root (top) directory run the following to install the required node modules:
### Installing the node modules
```
npm install
```
### Building the web UI
```
npm run build
```
You can also run `npm run watch` which will watch for changes and rebuild the HTML/CSS/JS automatically when changes are detected.
Please note that you must restart Mailpit (`go run .`) to run with the changes.
## Build the mailpit binary
One you have the assets compiled, you can build mailpit as follows:
```
go build -ldflags "-s -w"
```
## Building a stand-alone sendmail binary
This step is unnecessary, however if you do not intend to either symlink `sendmail` to mailpit or configure your existing sendmail to route mail to mailpit, you can optionally build a stand-alone sendmail binary.
```
cd sendmail
go build -ldflags "-s -w"
```

139
README.md
View File

@@ -1,60 +1,111 @@
# Mailpit
<h1 align="center">
Mailpit - email testing for developers
</h1>
Mailpit is an email testing tool for developers.
<div align="center">
<a href="https://github.com/axllent/mailpit/actions/workflows/tests.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg" alt="CI Tests status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/release-build.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg" alt="CI build status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg" alt="CI Docker build status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg" alt="Code quality"></a>
<a href="https://goreportcard.com/report/github.com/axllent/mailpit"><img src="https://goreportcard.com/badge/github.com/axllent/mailpit" alt="Go Report Card"></a>
<br>
<a href="https://github.com/axllent/mailpit/releases/latest"><img src="https://img.shields.io/github/v/release/axllent/mailpit.svg" alt="Latest release"></a>
<a href="https://hub.docker.com/r/axllent/mailpit"><img src="https://img.shields.io/docker/pulls/axllent/mailpit.svg" alt="Docker pulls"></a>
</div>
<br>
<p align="center">
<a href="https://mailpit.axllent.org">Website</a> •
<a href="https://mailpit.axllent.org/docs/">Documentation</a> •
<a href="https://mailpit.axllent.org/docs/api-v1/">API</a>
</p>
It acts as both an SMTP server, and provides a web interface to view all captured emails.
<hr>
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
**Mailpit** is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and includes an API for automated integration testing.
Mailpit was originally **inspired** by MailHog which is [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development or security updates for a few years now.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/server/ui-src/screenshot.png)
## Features
- Runs completely on a single binary
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
- Real-time web UI updates using web sockets for new mail
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
- Can handle tens of thousands of emails
## Planned features
- Optional HTTPS for web UI
- Optional basic authentication for web UI
- Optional authentication for SMTP
- Browser notifications for new mail (HTTPS only)
- Docker container
- Runs entirely from a single [static binary](https://mailpit.axllent.org/docs/install/) or multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)
- Modern web UI with advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/) to view emails (formatted HTML, highlighted HTML source, text, headers, raw source, and MIME attachments
including image thumbnails), including optional [HTTPS](https://mailpit.axllent.org/docs/configuration/http/) & [authentication](https://mailpit.axllent.org/docs/configuration/http/)
- [SMTP server](https://mailpit.axllent.org/docs/configuration/smtp/) with optional STARTTLS or SSL/TLS, authentication (including an "accept any" mode)
- A [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing
- Real-time web UI updates using web sockets for new mail & optional [browser notifications](https://mailpit.axllent.org/docs/usage/notifications/) when new mail is received
- Optional [POP3 server](https://mailpit.axllent.org/docs/configuration/pop3/) to download captured message directly into your email client
- [HTML check](https://mailpit.axllent.org/docs/usage/html-check/) to test & score mail client compatibility with HTML emails
- [Link check](https://mailpit.axllent.org/docs/usage/link-check/) to test message links (HTML & text) & linked images
- [Spam check](https://mailpit.axllent.org/docs/usage/spamassassin/) to test message "spamminess" using a running SpamAssassin server
- [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI
- Mobile and tablet HTML preview toggle in desktop mode
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
- `List-Unsubscribe` syntax validation
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
## Installation
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
The Mailpit web UI listens by default on `http://0.0.0.0:8025` and the SMTP port on `0.0.0.0:1025`.
To build mailpit from source see [building from source](README-BUILDING.md).
Mailpit runs as a single binary and can be installed in different ways:
### Install via package managers
- **Mac**: `brew install mailpit` (to run automatically in the background: `brew services start mailpit`)
- **Arch Linux**: available in the AUR as `mailpit`
- **FreeBSD**: `pkg install mailpit`
### Install via bash script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
### Download static binary (Windows, Linux and Mac)
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can be extracted and copied to your `$PATH`, or simply run as `./mailpit`.
### Docker
See [Docker instructions](https://mailpit.axllent.org/docs/install/docker/) for 386, amd64 & arm64 images.
### Compile from source
To build Mailpit from source, see [Building from source](https://mailpit.axllent.org/docs/install/source/).
## Usage
Run `mailpit -h` to see options. More information can be seen in [the docs](https://mailpit.axllent.org/docs/configuration/runtime-options/).
If installed using homebrew, you may run `brew services start mailpit` to always run mailpit automatically.
### Testing Mailpit
Please refer to [the documentation](https://mailpit.axllent.org/docs/install/testing/) on how to easily test email delivery to Mailpit.
### Configuring sendmail
There are several different options available:
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
```
sendmail_path = /usr/local/bin/mailpit sendmail
```
If mailpit is found on the same host as sendmail, you can symlink the mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if mailpit is running on default 1025 port).
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
You can build a mailpit-specific sendmail binary from source ( see [building from source](README-BUILDING.md)).
## Why rewrite MailHog?
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.
Mailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port.
A common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP.
Mailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).

19
SECURITY.md Normal file
View File

@@ -0,0 +1,19 @@
# Reporting security vulnerabilities
Your efforts to responsibly disclose your findings are appreciated.
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
Your report should include:
- Mailpit version
- A vulnerability description
- Reproduction steps (if applicable)
- Any other details you think are likely to be important
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
With your consent, your contributions will be publicly acknowledged.

36
cmd/dump.go Normal file
View File

@@ -0,0 +1,36 @@
package cmd
import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/dump"
"github.com/axllent/mailpit/internal/logger"
"github.com/spf13/cobra"
)
// dumpCmd represents the dump command
var dumpCmd = &cobra.Command{
Use: "dump <database> <output-dir>",
Short: "Dump all messages from a database to a directory",
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := dump.Sync(args[0]); err != nil {
logger.Log().Fatal(err)
}
},
}
func init() {
rootCmd.AddCommand(dumpCmd)
dumpCmd.Flags().SortFlags = false
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
}

173
cmd/ingest.go Normal file
View File

@@ -0,0 +1,173 @@
package cmd
import (
"bytes"
"fmt"
"io"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/axllent/mailpit/internal/logger"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
ingestRecent int
)
// ingestCmd represents the ingest command
var ingestCmd = &cobra.Command{
Use: "ingest <file|folder> ...[file|folder]",
Short: "Ingest a file or folder of emails for testing",
Long: `Ingest a file or folder of emails for testing.
This command will scan the folder for emails and deliver them via SMTP to a running
Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox).
The --recent flag will only consider files with a modification date within the last X days.`,
// Hidden: true,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var count int
var total int
var per100start = time.Now()
for _, a := range args {
err := filepath.Walk(a,
func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Log().Error(err)
return nil
}
if !isFile(path) {
return nil
}
if ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
return nil
}
f, err := os.Open(filepath.Clean(path))
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
defer f.Close() // #nosec
body, err := io.ReadAll(f)
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
logger.Log().Errorf("error parsing message body: %s", err.Error())
return nil
}
recipients := []string{}
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
recipients = append(recipients, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
recipients = append(recipients, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
recipients = append(recipients, a.Address)
}
}
if sendmail.FromAddr == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
sendmail.FromAddr = fromAddresses[0].Address
}
}
if len(recipients) == 0 {
// Bcc
recipients = []string{sendmail.FromAddr}
}
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
returnPath = fromAddresses[0].Address
}
}
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
if err != nil {
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
return nil
}
count++
total++
if count%100 == 0 {
logger.Log().Infof("[%s] 100 messages in %s", format(total), time.Since(per100start))
per100start = time.Now()
}
return nil
})
if err != nil {
logger.Log().Error(err)
}
}
},
}
func init() {
rootCmd.AddCommand(ingestCmd)
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
}
// 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
}
// Format a an integer 10000 => 10,000
func format(n int) string {
in := fmt.Sprintf("%d", n)
numOfDigits := len(in)
if n < 0 {
numOfDigits-- // First character is the - sign (not a digit)
}
numOfCommas := (numOfDigits - 1) / 3
out := make([]byte, len(in)+numOfCommas)
if n < 0 {
in, out[0] = in[1:], '-'
}
for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
out[j] = in[i]
if i == 0 {
return string(out)
}
if k++; k == 3 {
j, k = j-1, 0
out[j] = ','
}
}
}

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

36
cmd/reindex.go Normal file
View File

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

View File

@@ -1,40 +1,49 @@
// Package cmd is the main application
package cmd
import (
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/server/webhook"
"github.com/spf13/cobra"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mailpit",
Short: "Mailpit is an email testing tool for developers",
Long: `Mailpit is an email testing tool for developers.
It acts as an SMTP server, and provides a web interface to view all captured emails.`,
It acts as an SMTP server, and provides a web interface to view all captured emails.
Documentation:
https://github.com/axllent/mailpit
https://mailpit.axllent.org/docs/`,
Run: func(_ *cobra.Command, _ []string) {
if err := config.VerifyConfig(); err != nil {
logger.Log().Error(err.Error())
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)
}
},
@@ -60,27 +69,338 @@ func SendmailExecute() {
func init() {
// hide autocompletion
rootCmd.CompletionOptions.HiddenDefaultCmd = true
// rootCmd.Flags().SortFlags = false
// hide help
rootCmd.Flags().SortFlags = false
// hide help command
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
// hide help flag
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// defaults from envars if provided
if len(os.Getenv("MP_DATA_DIR")) > 0 {
config.DataDir = os.Getenv("MP_DATA_DIR")
// load and warn deprecated ENV vars
initDeprecatedConfigFromEnv()
// load environment variables
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
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 & 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.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)")
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
// SMTP server
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-require-starttls", config.SMTPRequireSTARTTLS, "Require SMTP client use STARTTLS")
rootCmd.Flags().BoolVar(&config.SMTPRequireTLS, "smtp-require-tls", config.SMTPRequireTLS, "Require client use SSL/TLS")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Allow insecure PLAIN & LOGIN SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
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 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)")
// SMTP forwarding
rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
// Chaos
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
// POP3 server
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
rootCmd.Flags().StringVar(&config.POP3TLSCert, "pop3-tls-cert", config.POP3TLSCert, "Optional TLS certificate for POP3 server - requires pop3-tls-key")
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.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")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-ssl-key", config.SMTPTLSKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().Lookup("ui-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ui-ssl-key").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-key").Deprecated = "use --ui-tls-key"
rootCmd.Flags().Lookup("smtp-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
// DEPRECATED FLAGS 2024/03/16
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
if len(os.Getenv("MP_DATABASE")) > 0 {
config.Database = os.Getenv("MP_DATABASE")
}
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
config.DisableWAL = getEnabledFromEnv("MP_DISABLE_WAL")
if len(os.Getenv("MP_COMPRESSION")) > 0 {
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
}
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"))
}
if len(os.Getenv("MP_MAX_AGE")) > 0 {
config.MaxAge = os.Getenv("MP_MAX_AGE")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
config.IgnoreDuplicateIDs = true
}
if len(os.Getenv("MP_LOG_FILE")) > 0 {
logger.LogFile = os.Getenv("MP_LOG_FILE")
}
if getEnabledFromEnv("MP_QUIET") {
logger.QuietLogging = true
}
if getEnabledFromEnv("MP_VERBOSE") {
logger.VerboseLogging = true
}
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages per mailbox")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// Web UI & API
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.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
config.AllowUntrustedTLS = true
}
if getEnabledFromEnv("MP_DISABLE_HTTP_COMPRESSION") {
config.DisableHTTPCompression = true
}
// SMTP server
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
}
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
if getEnabledFromEnv("MP_SMTP_REQUIRE_STARTTLS") {
config.SMTPRequireSTARTTLS = true
}
if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") {
config.SMTPRequireTLS = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if getEnabledFromEnv("MP_SMTP_STRICT_RFC_HEADERS") {
config.SMTPStrictRFCHeaders = true
}
if len(os.Getenv("MP_SMTP_MAX_RECIPIENTS")) > 0 {
config.SMTPMaxRecipients, _ = strconv.Atoi(os.Getenv("MP_SMTP_MAX_RECIPIENTS"))
}
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
}
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
smtpd.DisableReverseDNS = true
}
// SMTP relay
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
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 {
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
}
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
config.SMTPRelayConfig.OverrideFrom = os.Getenv("MP_SMTP_RELAY_OVERRIDE_FROM")
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
// SMTP forwarding
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
}
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
// Chaos
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
// POP3 server
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")
}
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
// Tagging
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 {
config.WebhookURL = os.Getenv("MP_WEBHOOK_URL")
}
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
}
// Demo mode
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")
}
// 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")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
logger.Log().Warn("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
}
// deprecated 2023/12/10
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
logger.Log().Warn("ENV MP_STRICT_RFC_HEADERS has been deprecated, use MP_SMTP_STRICT_RFC_HEADERS")
config.SMTPStrictRFCHeaders = true
}
// deprecated 2024/03.16
if getEnabledFromEnv("MP_SMTP_TLS_REQUIRED") {
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
func getEnabledFromEnv(k string) bool {
if len(os.Getenv(k)) > 0 {
v := strings.ToLower(os.Getenv(k))
return v == "1" || v == "true" || v == "yes"
}
return false
}

View File

@@ -1,22 +1,16 @@
package cmd
import (
"os"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
smtpAddr = "localhost:1025"
fromAddr string
)
// sendmailCmd represents the sendmail command
var sendmailCmd = &cobra.Command{
Use: "sendmail",
Short: "A sendmail command replacement",
Long: `A sendmail command replacement.
You can optionally create a symlink called 'sendmail' to the main binary.`,
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
@@ -24,10 +18,22 @@ You can optionally create a symlink called 'sendmail' to the main binary.`,
func init() {
rootCmd.AddCommand(sendmailCmd)
var ignored string
// these are simply repeated for cli consistency
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", "", "SMTP sender")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
// print out manual help screen
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
// these are simply repeated for cli consistency as cobra/viper does not allow
// multi-letter single-dash variables (-bs)
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "ignored-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "ignored-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("ignored-i", "i", false, "Ignored")
sendmailCmd.Flags().BoolP("ignored-o", "o", false, "Ignored")
sendmailCmd.Flags().BoolP("ignored-t", "t", false, "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-name", "F", "", "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-bits", "B", "", "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-errors", "e", "", "Ignored")
}

View File

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

View File

@@ -1,43 +1,529 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"net"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
)
var (
// SMTPListen to listen on <interface>:<port>
SMTPListen = "0.0.0.0:1025"
SMTPListen = "[::]:1025"
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
HTTPListen = "[::]:8025"
// DataDir for mail (optional)
DataDir string
// Database for mail (optional)
Database string
// DisableWAL will disable Write-Ahead Logging in SQLite
// @see https://sqlite.org/wal.html
DisableWAL bool
// Compression is the compression level used to store raw messages in the database:
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
Compression = 1
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID string
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// VerboseLogging for console output
VerboseLogging = false
// MaxAge is the maximum age of messages (auto-pruned every hour).
// Value can be either <int>h for hours or <int>d for days
MaxAge string
// NoLogging for testing
NoLogging = false
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
MaxAgeInHours int
// SSLCert @TODO
SSLCert string
// SSLKey @TODO
SSLKey string
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
// UITLSCert file
UITLSCert string
// UITLSKey file
UITLSKey string
// UIAuthFile for UI & API authentication
UIAuthFile string
// Webroot to define the base path for the UI and API
Webroot = "/"
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
DisableHTTPCompression bool
// SMTPTLSCert file
SMTPTLSCert string
// SMTPTLSKey file
SMTPTLSKey string
// SMTPRequireSTARTTLS to enforce the use of STARTTLS
// The only allowed commands are NOOP, EHLO, STARTTLS and QUIT (as specified in RFC 3207) until
// the connection is upgraded to TLS i.e. until STARTTLS is issued.
SMTPRequireSTARTTLS bool
// SMTPRequireTLS to allow only SSL/TLS connections for all connections
//
SMTPRequireTLS bool
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
// SMTPAuthAcceptAny accepts any username/password including none
SMTPAuthAcceptAny bool
// SMTPMaxRecipients is the maximum number of recipients a message may have.
// The SMTP RFC states that an server must handle a minimum of 100 recipients
// however some servers accept more.
SMTPMaxRecipients = 100
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
BlockRemoteCSSAndFonts = false
// CLITagsArg is used to map the CLI args
CLITagsArg string
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
// 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 the relay SMTP server
SMTPRelayConfigFile string
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
SMTPRelayConfig SMTPRelayConfigStruct
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// SMTPRelayAll is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
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
// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
SMTPForwardConfigFile string
// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
SMTPForwardConfig SMTPForwardConfigStruct
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
SMTPStrictRFCHeaders bool
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
SMTPAllowedRecipients string
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
SMTPAllowedRecipientsRegexp *regexp.Regexp
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
POP3Listen = "[::]:1110"
// POP3AuthFile for POP3 authentication
POP3AuthFile string
// POP3TLSCert TLS certificate
POP3TLSCert string
// POP3TLSKey TLS certificate key
POP3TLSKey string
// EnableSpamAssassin must be either <host>:<port> or "postmark"
EnableSpamAssassin string
// WebhookURL for calling
WebhookURL string
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
ContentSecurityPolicy string
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
AllowUntrustedTLS bool
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
// ChaosTriggers are parsed and set in the chaos module
ChaosTriggers string
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
DisableHTMLCheck = false
// DemoMode disables SMTP relay, link checking & HTTP send functionality
DemoMode = false
)
// AutoTag struct for auto-tagging
type autoTag struct {
Match string
Tags []string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
type SMTPRelayConfigStruct struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
OverrideFrom string `yaml:"override-from"` // allow overriding of the from 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"`
}
// SMTPForwardConfigStruct struct for parsing yaml & storing variables
type SMTPForwardConfigStruct struct {
To string `yaml:"to"` // comma-separated list of email addresses
Host string `yaml:"host"` // SMTP host
Port int `yaml:"port"` // SMTP port
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
}
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
cssFontRestriction := "*"
if BlockRemoteCSSAndFonts {
cssFontRestriction = "'self'"
}
if !re.MatchString(HTTPListen) {
return errors.New("HTTP bind should be in the format of <ip>:<port>")
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
// See server.middleWareFunc()
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
cssFontRestriction, cssFontRestriction,
)
if Database != "" && isDir(Database) {
Database = filepath.Join(Database, "mailpit.db")
}
if Compression < 0 || Compression > 3 {
return errors.New("[db] compression level must be between 0 and 3")
}
Label = tools.Normalize(Label)
if err := parseMaxAge(); err != nil {
return err
}
TenantID = DBTenantID(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
}
re := regexp.MustCompile(`.*:\d+$`)
if _, _, isSocket := tools.UnixSocket(SMTPListen); !isSocket && !re.MatchString(SMTPListen) {
return errors.New("[smtp] bind should be in the format of <ip>:<port>")
}
if _, _, isSocket := tools.UnixSocket(HTTPListen); !isSocket && !re.MatchString(HTTPListen) {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
if UIAuthFile != "" {
UIAuthFile = filepath.Clean(UIAuthFile)
if !isFile(UIAuthFile) {
return fmt.Errorf("[ui] HTTP password file not found or readable: %s", UIAuthFile)
}
b, err := os.ReadFile(UIAuthFile)
if err != nil {
return err
}
if err := auth.SetUIAuth(string(b)); err != nil {
return err
}
}
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
return errors.New("[ui] you must provide both a UI TLS certificate and a key")
}
if UITLSCert != "" {
UITLSCert = filepath.Clean(UITLSCert)
UITLSKey = filepath.Clean(UITLSKey)
if !isFile(UITLSCert) {
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("[ui] TLS key not found or readable: %s", UITLSKey)
}
}
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
}
if SMTPTLSCert != "" {
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
if !isFile(SMTPTLSCert) {
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
}
if !isFile(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")
} else if SMTPRequireSTARTTLS {
return errors.New("[smtp] STARTTLS cannot be required without an SMTP TLS certificate and key")
}
if SMTPRequireSTARTTLS && SMTPAuthAllowInsecure || SMTPRequireTLS && SMTPAuthAllowInsecure {
return errors.New("[smtp] TLS cannot be required with --smtp-auth-allow-insecure")
}
if SMTPRequireSTARTTLS && SMTPRequireTLS {
return errors.New("[smtp] TLS & STARTTLS cannot be required together")
}
if SMTPAuthFile != "" {
SMTPAuthFile = filepath.Clean(SMTPAuthFile)
if !isFile(SMTPAuthFile) {
return fmt.Errorf("[smtp] password file not found or readable: %s", SMTPAuthFile)
}
b, err := os.ReadFile(SMTPAuthFile)
if err != nil {
return err
}
if err := auth.SetSMTPAuth(string(b)); err != nil {
return err
}
if !SMTPAuthAllowInsecure {
// https://www.rfc-editor.org/rfc/rfc4954
// A server implementation MUST implement a configuration in which
// it does NOT permit any plaintext password mechanisms, unless either
// the STARTTLS [SMTP-TLS] command has been negotiated or some other
// mechanism that protects the session from password snooping has been
// provided. Server sites SHOULD NOT use any configuration which
// permits a plaintext password mechanism without such a protection
// mechanism against password snooping.
SMTPRequireSTARTTLS = true
}
}
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
return errors.New("[smtp] authentication cannot use both credentials and --smtp-auth-accept-any")
}
if SMTPTLSCert == "" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
if err := parseChaosTriggers(); err != nil {
return fmt.Errorf("[chaos] %s", err.Error())
}
if chaos.Enabled {
logger.Log().Info("[chaos] is enabled")
}
// POP3 server
if POP3TLSCert != "" {
POP3TLSCert = filepath.Clean(POP3TLSCert)
POP3TLSKey = filepath.Clean(POP3TLSKey)
if !isFile(POP3TLSCert) {
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
}
if !isFile(POP3TLSKey) {
return fmt.Errorf("[pop3] TLS key not found or readable: %s", POP3TLSKey)
}
}
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
}
if POP3Listen != "" {
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
if err != nil {
return fmt.Errorf("[pop3] %s", err.Error())
}
}
if POP3AuthFile != "" {
POP3AuthFile = filepath.Clean(POP3AuthFile)
if !isFile(POP3AuthFile) {
return fmt.Errorf("[pop3] password file not found or readable: %s", POP3AuthFile)
}
b, err := os.ReadFile(POP3AuthFile)
if err != nil {
return err
}
if err := auth.SetPOP3Auth(string(b)); err != nil {
return err
}
}
// Web root
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.@]`)
if validWebrootRe.MatchString(Webroot) {
return fmt.Errorf("invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]", Webroot)
}
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
if WebhookURL != "" && !isValidURL(WebhookURL) {
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)
if err := spamassassin.Ping(); err != nil {
logger.Log().Warnf("[spamassassin] ping: %s", err.Error())
}
}
// 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 != "" {
restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile smtp-allowed-recipients regexp: %s", err.Error())
}
SMTPAllowedRecipientsRegexp = restrictRegexp
logger.Log().Infof("[smtp] only allowing recipients matching regexp: %s", SMTPAllowedRecipients)
}
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
return err
}
// separate relay config validation to account for environment variables
if err := validateRelayConfig(); err != nil {
return err
}
if !ReleaseEnabled && SMTPRelayAll || !ReleaseEnabled && SMTPRelayMatching != "" {
return errors.New("[relay] a relay configuration must be set to auto-relay any messages")
}
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("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
return err
}
// separate forwarding config validation to account for environment variables
if err := validateForwardConfig(); err != nil {
return err
}
if DemoMode {
MaxMessages = 1000
// this deserves a warning
logger.Log().Info("demo mode enabled")
}
return nil

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
}

51
config/utils.go Normal file
View File

@@ -0,0 +1,51 @@
package config
import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/axllent/mailpit/internal/tools"
)
// IsFile returns whether a file exists and is readable
func isFile(path string) bool {
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 err != nil || os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}
func isValidURL(s string) bool {
u, err := url.ParseRequestURI(s)
if err != nil {
return false
}
return strings.HasPrefix(u.Scheme, "http")
}
// DBTenantID converts a tenant ID to a DB-friendly value if set
func DBTenantID(s string) string {
s = tools.Normalize(s)
if s != "" {
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
s = re.ReplaceAllString(s, "_")
if !strings.HasSuffix(s, "_") {
s = s + "_"
}
}
return s
}

282
config/validators.go Normal file
View File

@@ -0,0 +1,282 @@
package config
import (
"errors"
"fmt"
"net/mail"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"gopkg.in/yaml.v3"
)
// Parse the --max-age value (if set)
func parseMaxAge() error {
if MaxAge == "" {
return nil
}
re := regexp.MustCompile(`^\d+(h|d)$`)
if !re.MatchString(MaxAge) {
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
}
if strings.HasSuffix(MaxAge, "h") {
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
if err != nil {
return err
}
MaxAgeInHours = hours
return nil
}
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
if err != nil {
return err
}
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
MaxAgeInHours = days * 24
return nil
}
// Parse the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[relay] configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("[relay] host not set")
}
// DEPRECATED 2024/03/12
if SMTPRelayConfig.RecipientAllowlist != "" {
logger.Log().Warn("[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
if SMTPRelayConfig.AllowedRecipients == "" {
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
}
}
return nil
}
// Validate the SMTPRelayConfig (if Host is set)
func validateRelayConfig() error {
if SMTPRelayConfig.Host == "" {
return nil
}
if SMTPRelayConfig.Port == 0 {
SMTPRelayConfig.Port = 25 // default
}
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
SMTPRelayConfig.Auth = "none"
} else if SMTPRelayConfig.Auth == "plain" {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[relay] host username or password not set for PLAIN authentication")
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[relay] host username or password not set for LOGIN authentication")
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
return fmt.Errorf("[relay] host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
}
if SMTPRelayConfig.AllowedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = re
logger.Log().Infof("[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("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
}
SMTPRelayConfig.BlockedRecipientsRegexp = re
logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
}
if SMTPRelayConfig.OverrideFrom != "" {
m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
}
SMTPRelayConfig.OverrideFrom = m.Address
}
ReleaseEnabled = true
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
return nil
}
// Parse the SMTPForwardConfigFile (if set)
func parseForwardConfig(c string) error {
if c == "" {
return nil
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[forward] configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
return err
}
if SMTPForwardConfig.Host == "" {
return errors.New("[forward] host not set")
}
return nil
}
// Validate the SMTPForwardConfig (if Host is set)
func validateForwardConfig() error {
if SMTPForwardConfig.Host == "" {
return nil
}
if SMTPForwardConfig.Port == 0 {
SMTPForwardConfig.Port = 25 // default
}
SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
SMTPForwardConfig.Auth = "none"
} else if SMTPForwardConfig.Auth == "plain" {
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
}
} else if SMTPForwardConfig.Auth == "login" {
SMTPForwardConfig.Auth = "login"
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
}
} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
SMTPForwardConfig.Auth = "cram-md5"
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
}
if SMTPForwardConfig.To == "" {
return errors.New("[forward] To addresses missing")
}
to := []string{}
addresses := strings.Split(SMTPForwardConfig.To, ",")
for _, a := range addresses {
a = strings.TrimSpace(a)
m, err := mail.ParseAddress(a)
if err != nil {
return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
}
to = append(to, m.Address)
}
if len(to) == 0 {
return errors.New("[forward] no valid To addresses found")
}
// overwrite the To field with the cleaned up list
SMTPForwardConfig.To = strings.Join(to, ",")
if SMTPForwardConfig.OverrideFrom != "" {
m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
}
SMTPForwardConfig.OverrideFrom = m.Address
}
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
return nil
}
func parseChaosTriggers() error {
if ChaosTriggers == "" {
return nil
}
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
parts := strings.Split(ChaosTriggers, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if !re.MatchString(p) {
return fmt.Errorf("invalid argument: %s", p)
}
matches := re.FindAllStringSubmatch(p, 1)
key := matches[0][1]
errorCode, err := strconv.Atoi(matches[0][2])
if err != nil {
return err
}
probability, err := strconv.Atoi(matches[0][3])
if err != nil {
return err
}
if err := chaos.Set(key, errorCode, probability); err != nil {
return err
}
}
return nil
}

View File

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

View File

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

View File

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

44
esbuild.config.mjs Normal file
View File

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

85
go.mod
View File

@@ -1,52 +1,63 @@
module github.com/axllent/mailpit
go 1.18
go 1.23.0
toolchain go1.23.2
require (
github.com/PuerkitoBio/goquery v1.10.2
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.10.0
github.com/k3a/html2text v1.0.8
github.com/mhale/smtpd v0.8.0
github.com/ostafen/clover/v2 v2.0.0-alpha.2
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.3.0
github.com/klauspost/compress v1.18.0
github.com/kovidgoyal/imaging v1.6.4
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.2.0
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/tg123/go-htpasswd v1.2.3
github.com/vanng822/go-premailer v1.23.0
golang.org/x/net v0.37.0
golang.org/x/text v0.23.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.36.1
)
require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.6+incompatible // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/orderedcode v0.0.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.3.1 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.7.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/sys v0.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)

425
go.sum
View File

@@ -1,287 +1,252 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/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/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/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/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw=
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/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.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.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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/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/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
github.com/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=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/ostafen/clover/v2 v2.0.0-alpha.2 h1:PgOWohvpj4qNCyASJ7Q8Ke8ld/wsoi+dQJ05b1ebwus=
github.com/ostafen/clover/v2 v2.0.0-alpha.2/go.mod h1:7UyIG46NglzTDRKB4LJiS/enXpuo67Lj05eM8mdhL6M=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
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/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tg123/go-htpasswd v1.2.3 h1:ALR6ZBIc2m9u70m+eAWUFt5p43ISbIvAvRFYzZPTOY8=
github.com/tg123/go-htpasswd v1.2.3/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.23.0 h1:vZp2wuz1jb4q/DurUV18VGjXWtTFYZHwTCw2EAWKO74=
github.com/vanng822/go-premailer v1.23.0/go.mod h1:0+z0UJ6ZGQatzkWlaQNl50M7fLz5f6FcP8V2p0oie88=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 h1:UreQrH7DbFXSi9ZFox6FNT3WBooWmdANpU+IfkT1T4I=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.0.0-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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
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.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ=
modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

98
install.sh Normal file
View File

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

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

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

163
internal/dump/dump.go Normal file
View File

@@ -0,0 +1,163 @@
// Package dump is used to export all messages from mailpit into a directory
package dump
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path"
"regexp"
"strings"
"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/apiv1"
)
var (
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
outDir string
// Base URL of mailpit instance
base string
// URL is the base URL of a remove Mailpit instance
URL string
summary = []storage.MessageSummary{}
)
// Sync will sync all messages from the specified database or API to the specified output directory
func Sync(d string) error {
outDir = path.Clean(d)
if URL != "" {
if !linkRe.MatchString(URL) {
return errors.New("Invalid URL")
}
base = strings.TrimRight(URL, "/") + "/"
}
if base == "" && config.Database == "" {
return errors.New("No database or API URL specified")
}
if !tools.IsDir(outDir) {
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
return err
}
}
if err := loadIDs(); err != nil {
return err
}
if err := saveMessages(); err != nil {
return err
}
return nil
}
// LoadIDs will load all message IDs from the specified database or API
func loadIDs() error {
if base != "" {
// remote
logger.Log().Debugf("Fetching messages summary from %s", base)
res, err := http.Get(base + "api/v1/messages?limit=0")
if err != nil {
return err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
var data apiv1.MessagesSummary
if err := json.Unmarshal(body, &data); err != nil {
return err
}
summary = data.Messages
} else {
// make sure the database isn't pruned while open
config.MaxMessages = 0
var err error
// local database
if err = storage.InitDB(); err != nil {
return err
}
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
summary, err = storage.List(0, 0, 0)
if err != nil {
return err
}
}
if len(summary) == 0 {
return errors.New("No messages found")
}
return nil
}
func saveMessages() error {
for _, m := range summary {
out := path.Join(outDir, m.ID+".eml")
// skip if message exists
if tools.IsFile(out) {
continue
}
var b []byte
if base != "" {
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
if err != nil {
logger.Log().Errorf("Error fetching message %s: %s", m.ID, err.Error())
continue
}
b, err = io.ReadAll(res.Body)
if err != nil {
logger.Log().Errorf("Error fetching message %s: %s", m.ID, err.Error())
continue
}
} else {
var err error
b, err = storage.GetMessageRaw(m.ID)
if err != nil {
logger.Log().Errorf("Error fetching message %s: %s", m.ID, err.Error())
continue
}
}
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
logger.Log().Errorf("Error writing message %s: %s", m.ID, err.Error())
continue
}
_ = os.Chtimes(out, m.Created, m.Created)
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
}
return nil
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
# HTML check
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
// Package htmlcheck is used for parsing HTML and returning
// HTML compatibility errors and warnings
package htmlcheck
import (
"embed"
"encoding/json"
"regexp"
)
//go:embed caniemail-data.json
var embeddedFS embed.FS
var (
cie = CanIEmail{}
noteMatch = regexp.MustCompile(` #(\d)+$`)
// LimitFamilies will limit results to families if set
LimitFamilies = []string{}
// LimitPlatforms will limit results to platforms if set
LimitPlatforms = []string{}
// LimitClients will limit results to clients if set
LimitClients = []string{}
)
// CanIEmail struct for JSON data
type CanIEmail struct {
APIVersion string `json:"api_version"`
LastUpdateDate string `json:"last_update_date"`
// NiceNames map[string]string `json:"last_update_date"`
NiceNames struct {
Family map[string]string `json:"family"`
Platform map[string]string `json:"platform"`
Support map[string]string `json:"support"`
Category map[string]string `json:"category"`
} `json:"nicenames"`
Data []JSONResult `json:"data"`
}
// JSONResult struct for CanIEmail Data
type JSONResult struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Category string `json:"category"`
Tags []string `json:"tags"`
Keywords string `json:"keywords"`
LastTestDate string `json:"last_test_date"`
TestURL string `json:"test_url"`
TestResultsURL string `json:"test_results_url"`
Stats map[string]interface{} `json:"stats"`
Notes string `json:"notes"`
NotesByNumber map[string]string `json:"notes_by_num"`
}
// Load the JSON data
func loadJSONData() error {
if cie.APIVersion != "" {
return nil
}
b, err := embeddedFS.ReadFile("caniemail-data.json")
if err != nil {
return err
}
cie = CanIEmail{}
return json.Unmarshal(b, &cie)
}

View File

@@ -0,0 +1,215 @@
package htmlcheck
import "regexp"
// HTML tests
var htmlTests = map[string]string{
// body check is manually done because it always exists in *goquery.Document
"html-body": "body",
// HTML tests
"html-object": "object, embed, image, pdf",
"html-link": "link",
"html-hr": "hr",
"html-dialog": "dialog",
"html-srcset": "[srcset]",
"html-picture": "picture",
"html-svg": "svg",
"html-progress": "progress",
"html-required": "[required]",
"html-meter": "meter",
"html-audio": "audio",
"html-form": "form",
"html-input-submit": "submit",
"html-button-reset": "button[type=\"reset\"]",
"html-button-submit": "submit, button[type=\"submit\"]",
"html-base": "base",
"html-input-checkbox": "checkbox",
"html-input-hidden": "[type=\"hidden\"]",
"html-input-radio": "radio",
"html-input-text": "input[type=\"text\"]",
"html-video": "video",
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
"html-select": "select",
"html-textarea": "textarea",
"html-anchor-links": "a[href^=\"#\"]",
"html-style": "style",
"html-image-maps": "map, img[usemap]",
}
// Image tests using regex to match against img[src]
var imageRegexpTests = map[string]*regexp.Regexp{
"image-apng": regexp.MustCompile(`(?i)\.apng$`),
"image-avif": regexp.MustCompile(`(?i)\.avif$`),
"image-base64": regexp.MustCompile(`^(?i)data:image\/`),
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`),
"image-gif": regexp.MustCompile(`(?i)\.gif$`),
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`),
"image-heif": regexp.MustCompile(`(?i)\.heif$`),
"image-ico": regexp.MustCompile(`(?i)\.ico$`),
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`),
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`),
"image-svg": regexp.MustCompile(`(?i)\.svg$`),
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`),
"image-webp": regexp.MustCompile(`(?i)\.webp$`),
}
// inline attribute <match>=""
var styleInlineAttributes = map[string]string{
"css-background-color": "[bgcolor]",
"css-background": "[background]",
"css-border": "[border]",
"css-height": "[height]",
"css-padding": "[padding]",
"css-width": "[width]",
}
// inline style="<match>"
var cssInlineRegexTests = map[string]*regexp.Regexp{
"css-accent-color": regexp.MustCompile(`(?i)(^|\s|;)accent-color(\s+)?:`),
"css-align-items": regexp.MustCompile(`(?i)(^|\s|;)align-items(\s+)?:`),
"css-aspect-ratio": regexp.MustCompile(`(?i)(^|\s|;)aspect-ratio(\s+)?:`),
"css-background-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)background-blend-mode(\s+)?:`),
"css-background-clip": regexp.MustCompile(`(?i)(^|\s|;)background-clip(\s+)?:`),
"css-background-color": regexp.MustCompile(`(?i)(^|\s|;)background-color(\s+)?:`),
"css-background-image": regexp.MustCompile(`(?i)(^|\s|;)background-image(\s+)?:`),
"css-background-origin": regexp.MustCompile(`(?i)(^|\s|;)background-origin(\s+)?:`),
"css-background-position": regexp.MustCompile(`(?i)(^|\s|;)background-position(\s+)?:`),
"css-background-repeat": regexp.MustCompile(`(?i)(^|\s|;)background-repeat(\s+)?:`),
"css-background-size": regexp.MustCompile(`(?i)(^|\s|;)background-size(\s+)?:`),
"css-background": regexp.MustCompile(`(?i)(^|\s|;)background(\s+)?:`),
"css-block-inline-size": regexp.MustCompile(`(?i)(^|\s|;)block-inline-size(\s+)?:`),
"css-border-image": regexp.MustCompile(`(?i)(^|\s|;)border-image(\s+)?:`),
"css-border-inline-block-individual": regexp.MustCompile(`(?i)(^|\s|;)border-inline(\s+)?:`),
"css-border-radius": regexp.MustCompile(`(?i)(^|\s|;)border-radius(\s+)?:`),
"css-border": regexp.MustCompile(`(?i)(^|\s|;)border(\s+)?:`),
"css-box-shadow": regexp.MustCompile(`(?i)(^|\s|;)box-shadow(\s+)?:`),
"css-box-sizing": regexp.MustCompile(`(?i)(^|\s|;)box-sizing(\s+)?:`),
"css-caption-side": regexp.MustCompile(`(?i)(^|\s|;)caption-side(\s+)?:`),
"css-clip-path": regexp.MustCompile(`(?i)(^|\s|;)clip-path(\s+)?:`),
"css-column-count": regexp.MustCompile(`(?i)(^|\s|;)column-count(\s+)?:`),
"css-column-layout-properties": regexp.MustCompile(`(?i)(^|\s|;)column-layout-properties(\s+)?:`),
"css-conic-gradient": regexp.MustCompile(`(?i)(^|\s|;)conic-gradient(\s+)?:`),
"css-direction": regexp.MustCompile(`(?i)(^|\s|;)direction(\s+)?:`),
"css-display-flex": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:(\s+)?flex($|\s|;)`),
"css-display-grid": regexp.MustCompile(`(?i)(^|\s|;)display:grid`),
"css-display-none": regexp.MustCompile(`(?i)(^|\s|;)display:none`),
"css-display": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:`),
"css-filter": regexp.MustCompile(`(?i)(^|\s|;)filter(\s+)?:`),
"css-flex-direction": regexp.MustCompile(`(?i)(^|\s|;)flex-direction(\s+)?:`),
"css-flex-wrap": regexp.MustCompile(`(?i)(^|\s|;)flex-wrap(\s+)?:`),
"css-float": regexp.MustCompile(`(?i)(^|\s|;)float(\s+)?:`),
"css-font-kerning": regexp.MustCompile(`(?i)(^|\s|;)font-kerning(\s+)?:`),
"css-font-weight": regexp.MustCompile(`(?i)(^|\s|;)font-weight(\s+)?:`),
"css-font": regexp.MustCompile(`(?i)(^|\s|;)font(\s+)?:`),
"css-gap": regexp.MustCompile(`(?i)(^|\s|;)gap(\s+)?:`),
"css-grid-template": regexp.MustCompile(`(?i)(^|\s|;)grid-template(\s+)?:`),
"css-height": regexp.MustCompile(`(?i)(^|\s|;)height(\s+)?:`),
"css-hyphens": regexp.MustCompile(`(?i)(^|\s|;)hyphens(\s+)?:`),
"css-important": regexp.MustCompile(`(?i)!important($|\s|;)`),
"css-inline-size": regexp.MustCompile(`(?i)(^|\s|;)inline-size(\s+)?:`),
"css-intrinsic-size": regexp.MustCompile(`(?i)(^|\s|;)intrinsic-size(\s+)?:`),
"css-justify-content": regexp.MustCompile(`(?i)(^|\s|;)justify-content(\s+)?:`),
"css-letter-spacing": regexp.MustCompile(`(?i)(^|\s|;)letter-spacing(\s+)?:`),
"css-line-height": regexp.MustCompile(`(?i)(^|\s|;)line-height(\s+)?:`),
"css-list-style-image": regexp.MustCompile(`(?i)(^|\s|;)list-style-image(\s+)?:`),
"css-list-style-position": regexp.MustCompile(`(?i)(^|\s|;)list-style-position(\s+)?:`),
"css-list-style": regexp.MustCompile(`(?i)(^|\s|;)list-style(\s+)?:`),
"css-margin-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-block-(start|end)(\s+)?:`),
"css-margin-inline-block": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-block(\s+)?:`),
"css-margin-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-(start|end)(\s+)?:`),
"css-margin-inline": regexp.MustCompile(`(?i)(^|\s|;)margin-inline(\s+)?:`),
"css-margin": regexp.MustCompile(`(?i)(^|\s|;)margin(\s+)?:`),
"css-max-block-size": regexp.MustCompile(`(?i)(^|\s|;)max-block-size(\s+)?:`),
"css-max-height": regexp.MustCompile(`(?i)(^|\s|;)max-height(\s+)?:`),
"css-max-width": regexp.MustCompile(`(?i)(^|\s|;)max-width(\s+)?:`),
"css-min-height": regexp.MustCompile(`(?i)(^|\s|;)min-height(\s+)?:`),
"css-min-inline-size": regexp.MustCompile(`(?i)(^|\s|;)min-inline-size(\s+)?:`),
"css-min-width": regexp.MustCompile(`(?i)(^|\s|;)min-width(\s+)?:`),
"css-mix-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)mix-blend-mode(\s+)?:`),
"css-modern-color": regexp.MustCompile(`(?i)(^|\s|;)modern-color(\s+)?:`),
"css-object-fit": regexp.MustCompile(`(?i)(^|\s|;)object-fit(\s+)?:`),
"css-object-position": regexp.MustCompile(`(?i)(^|\s|;)object-position(\s+)?:`),
"css-opacity": regexp.MustCompile(`(?i)(^|\s|;)opacity(\s+)?:`),
"css-outline-offset": regexp.MustCompile(`(?i)(^|\s|;)outline-offset(\s+)?:`),
"css-outline": regexp.MustCompile(`(?i)(^|\s|;)outline(\s+)?:`),
"css-overflow-wrap": regexp.MustCompile(`(?i)(^|\s|;)overflow-wrap(\s+)?:`),
"css-overflow": regexp.MustCompile(`(?i)(^|\s|;)overflow(\s+)?:`),
"css-padding-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-block-(start|end)(\s+)?:`),
"css-padding-inline-block": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-block(\s+)?:`),
"css-padding-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-(start|end)(\s+)?:`),
"css-padding": regexp.MustCompile(`(?i)(^|\s|;)padding(\s+)?:`),
"css-position": regexp.MustCompile(`(?i)(^|\s|;)position(\s+)?:`),
"css-radial-gradient": regexp.MustCompile(`(?i)(^|\s|;)radial-gradient(\s+)?:`),
"css-rgb": regexp.MustCompile(`(?i)(\s|:)rgb\(`),
"css-rgba": regexp.MustCompile(`(?i)(\s|:)rgba\(`),
"css-scroll-snap": regexp.MustCompile(`(?i)(^|\s|;)roll-snap(\s+)?:`),
"css-tab-size": regexp.MustCompile(`(?i)(^|\s|;)tab-size(\s+)?:`),
"css-table-layout": regexp.MustCompile(`(?i)(^|\s|;)table-layout(\s+)?:`),
"css-text-align-last": regexp.MustCompile(`(?i)(^|\s|;)text-align-last(\s+)?:`),
"css-text-align": regexp.MustCompile(`(?i)(^|\s|;)text-align(\s+)?:`),
"css-text-decoration-color": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-color(\s+)?:`),
"css-text-decoration-thickness": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-thickness(\s+)?:`),
"css-text-decoration": regexp.MustCompile(`(?i)(^|\s|;)text-decoration(\s+)?:`),
"css-text-emphasis-position": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis-position(\s+)?:`),
"css-text-emphasis": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis(\s+)?:`),
"css-text-indent": regexp.MustCompile(`(?i)(^|\s|;)text-indent(\s+)?:`),
"css-text-overflow": regexp.MustCompile(`(?i)(^|\s|;)text-overflow(\s+)?:`),
"css-text-shadow": regexp.MustCompile(`(?i)(^|\s|;)text-shadow(\s+)?:`),
"css-text-transform": regexp.MustCompile(`(?i)(^|\s|;)text-transform(\s+)?:`),
"css-text-underline-offset": regexp.MustCompile(`(?i)(^|\s|;)text-underline-offset(\s+)?:`),
"css-transform": regexp.MustCompile(`(?i)(^|\s|;)transform(\s+)?:`),
"css-unit-calc": regexp.MustCompile(`(?i)(\s|:)calc\(`),
"css-variables": regexp.MustCompile(`(?i)(^|\s|;)variables(\s+)?:`),
"css-visibility": regexp.MustCompile(`(?i)(^|\s|;)visibility(\s+)?:`),
"css-white-space": regexp.MustCompile(`(?i)(^|\s|;)white-space(\s+)?:`),
"css-width": regexp.MustCompile(`(?i)(^|\s|;)width(\s+)?:`),
"css-word-break": regexp.MustCompile(`(?i)(^|\s|;)word-break(\s+)?:`),
"css-writing-mode": regexp.MustCompile(`(?i)(^|\s|;)writing-mode(\s+)?:`),
"css-z-index": regexp.MustCompile(`(?i)(^|\s|;)z-index(\s+)?:`),
}
// some CSS tests using regex for things that can't be merged inline
var cssRegexpTests = map[string]*regexp.Regexp{
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`),
"css-at-import": regexp.MustCompile(`(?mi)@import\s`),
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`),
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`),
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`),
"css-pseudo-class-active": regexp.MustCompile(`:active`),
"css-pseudo-class-checked": regexp.MustCompile(`:checked`),
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`),
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`),
"css-pseudo-class-focus": regexp.MustCompile(`:focus`),
"css-pseudo-class-has": regexp.MustCompile(`:has`),
"css-pseudo-class-hover": regexp.MustCompile(`:hover`),
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`),
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`),
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`),
"css-pseudo-class-link": regexp.MustCompile(`:link`),
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`),
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`),
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`),
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`),
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`),
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`),
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`),
"css-pseudo-class-target": regexp.MustCompile(`:target`),
"css-pseudo-class-visited": regexp.MustCompile(`:visited`),
"css-pseudo-element-after": regexp.MustCompile(`:after`),
"css-pseudo-element-before": regexp.MustCompile(`:before`),
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`),
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`),
"css-pseudo-element-marker": regexp.MustCompile(`::marker`),
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`),
}
// some CSS tests using regex for units
var cssRegexpUnitTests = map[string]*regexp.Regexp{
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`),
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`),
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`),
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`),
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`),
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`),
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`),
}

251
internal/htmlcheck/css.go Normal file
View File

@@ -0,0 +1,251 @@
package htmlcheck
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/vanng822/go-premailer/premailer"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Go cannot calculate any rendered CSS attributes, so we merge all styles
// into the HTML and detect elements with styles containing the keywords.
func runCSSTests(html string) ([]Warning, int, error) {
results := []Warning{}
totalTests := 0
inlined, err := inlineRemoteCSS(html)
if err != nil {
inlined = html
}
// merge all CSS inline
merged, err := mergeInlineCSS(inlined)
if err != nil {
merged = inlined
}
reader := strings.NewReader(merged)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return results, totalTests, err
}
inlineStyleResults := testInlineStyles(doc)
totalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)
for key, count := range inlineStyleResults {
result, err := cie.getTest(key)
if err == nil {
result.Score.Found = count
results = append(results, result)
}
}
// get a list of all generated styles from all nodes
allNodeStyles := []string{}
for _, n := range doc.Find("*[style]").Nodes {
style, err := tools.GetHTMLAttributeVal(n, "style")
if err == nil {
allNodeStyles = append(allNodeStyles, style)
}
}
for key, re := range cssRegexpUnitTests {
totalTests++
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
found := 0
// loop through all styles to count total
for _, styles := range allNodeStyles {
found = found + len(re.FindAllString(styles, -1))
}
if found > 0 {
result.Score.Found = found
results = append(results, result)
}
}
// get all inline CSS block data
reader = strings.NewReader(inlined)
// Load the HTML document
doc, _ = goquery.NewDocumentFromReader(reader)
cssCode := ""
for _, n := range doc.Find("style").Nodes {
for c := n.FirstChild; c != nil; c = c.NextSibling {
cssCode = cssCode + c.Data
}
}
for key, re := range cssRegexpTests {
totalTests++
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
found := len(re.FindAllString(cssCode, -1))
if found > 0 {
result.Score.Found = found
results = append(results, result)
}
}
return results, totalTests, nil
}
// MergeInlineCSS merges header CSS into element attributes
func mergeInlineCSS(html string) (string, error) {
options := premailer.NewOptions()
// options.RemoveClasses = true
// options.CssToAttributes = false
options.KeepBangImportant = true
pre, err := premailer.NewPremailerFromString(html, options)
if err != nil {
return "", err
}
return pre.Transform()
}
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
func inlineRemoteCSS(h string) (string, error) {
reader := strings.NewReader(h)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return h, err
}
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range remoteCSS {
attributes := link.Attr
for _, a := range attributes {
if a.Key == "href" {
if !isURL(a.Val) {
// skip invalid URL
continue
}
if config.BlockRemoteCSSAndFonts {
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
return h, nil
}
resp, err := downloadToBytes(a.Val)
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
continue
}
// create new <style> block and insert downloaded CSS
styleBlock := &html.Node{
Type: html.ElementNode,
Data: "style",
DataAtom: atom.Style,
}
styleBlock.AppendChild(&html.Node{
Type: html.TextNode,
Data: string(resp),
})
link.Parent.AppendChild(styleBlock)
}
}
}
newDoc, err := doc.Html()
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", err.Error())
return h, err
}
return newDoc, nil
}
// DownloadToBytes returns a []byte slice from a URL
func downloadToBytes(url string) ([]byte, error) {
client := http.Client{
Timeout: 5 * time.Second,
}
// Get the link response data
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err := fmt.Errorf("Error downloading %s", url)
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
// Test if str is a URL
func isURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// Test the HTML for inline CSS styles and styling attributes
func testInlineStyles(doc *goquery.Document) map[string]int {
matches := make(map[string]int)
// find all elements containing a style attribute
styles := doc.Find("[style]").Nodes
for _, s := range styles {
style, err := tools.GetHTMLAttributeVal(s, "style")
if err != nil {
continue
}
for id, test := range cssInlineRegexTests {
if test.MatchString(style) {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
}
// find all elements containing styleInlineAttributes
for id, test := range styleInlineAttributes {
a := doc.Find(test).Nodes
if len(a) > 0 {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
return matches
}

102
internal/htmlcheck/html.go Normal file
View File

@@ -0,0 +1,102 @@
package htmlcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/tools"
)
// HTML tests
func runHTMLTests(html string) ([]Warning, int, error) {
results := []Warning{}
totalTests := 0
reader := strings.NewReader(html)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return results, totalTests, err
}
// Almost all <script> is bad
scripts := len(doc.Find("script:not([type=\"application/ld+json\"]):not([type=\"application/json\"])").Nodes)
if scripts > 0 {
var result = Warning{}
result.Title = "<script> element"
result.Slug = "html-script"
result.Category = "html"
result.Description = "JavaScript is not supported in any email client."
result.Tags = []string{}
result.Results = []Result{}
result.NotesByNumber = map[string]string{}
result.Score.Found = scripts
result.Score.Supported = 0
result.Score.Partial = 0
result.Score.Unsupported = 100
results = append(results, result)
totalTests++
}
for key, test := range htmlTests {
totalTests++
if test == "body" {
re := regexp.MustCompile(`(?im)</body>`)
if re.MatchString(html) {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = 1
results = append(results, result)
}
} else if len(doc.Find(test).Nodes) > 0 {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
totalTests++
result.Score.Found = len(doc.Find(test).Nodes)
results = append(results, result)
}
}
// find all images
images := doc.Find("img[src]").Nodes
imageResults := make(map[string]int)
totalTests = totalTests + len(imageRegexpTests)
for _, image := range images {
src, err := tools.GetHTMLAttributeVal(image, "src")
if err != nil {
continue
}
for key, test := range imageRegexpTests {
if test.MatchString(src) {
matches, exists := imageResults[key]
if exists {
imageResults[key] = matches + 1
} else {
imageResults[key] = 1
}
}
}
}
for key, found := range imageResults {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = found
results = append(results, result)
}
return results, totalTests, nil
}

View File

@@ -0,0 +1,81 @@
package htmlcheck
import (
"fmt"
"sort"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
func TestInlineStyleDetection(t *testing.T) {
/// tests should contain the HTML test, and expected test results in alphabetical order
tests := map[string]string{}
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color: green; transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color:green; transform :rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="TRANSFORM:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="ignore-transform: something">Heading</h1>`] = "" // no match
tests[`<h1 style="text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="text-transform: uppercase; text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="test-transform: uppercase">Heading</h1>`] = "" // no match
tests[`<h1 style="padding-inline-start: 5rem">Heading</h1>`] = "css-padding-inline-start-end"
tests[`<h1 style="margin-inline-end: 5rem">Heading</h1>`] = "css-margin-inline-start-end"
tests[`<h1 style="margin-inline-middle: 5rem">Heading</h1>`] = "" // no match
tests[`<h1 style="color:green!important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green !important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green!important;">Heading</h1>`] = "css-important"
tests[`<h1 style="color:green!important-stuff;">Heading</h1>`] = "" // no match
tests[`<h1 style="background-image:url('img.jpg')">Heading</h1>`] = "css-background-image"
tests[`<h1 style="background-image:url('img.jpg'); color: green">Heading</h1>`] = "css-background-image"
tests[`<h1 style=" color:green; background-image:url('img.jpg');">Heading</h1>`] = "css-background-image"
tests[`<h1 style="display : flex ;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="DISPLAY:FLEX;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="display: flexing;">Heading</h1>`] = "css-display" // should not match css-display-flex rule
tests[`<h1 style="line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)">Heading</h1>`] = "css-line-height,css-opacity,css-unit-calc,css-width"
tests[`<h1 style="color: rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgba(255,255,255, 0);">Heading</h1>`] = "css-rgba"
tests[`<h1 style="border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);">Heading</h1>`] = "css-border,css-rgb,css-rgba"
tests[`<h1 border="2">Heading</h1>`] = "css-border"
tests[`<h1 border="2" background="green">Heading</h1>`] = "css-background,css-border"
tests[`<h1 BORDER="2" BACKGROUND="GREEN">Heading</h1>`] = "css-background,css-border"
tests[`<h1 border-something="2" background-something="green">Heading</h1>`] = "" // no match
tests[`<h1 border="2" style="border: solid green 1px!important">Heading</h1>`] = "css-border,css-important"
for html, expected := range tests {
reader := strings.NewReader(html)
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
t.Log("error ", err)
t.Fail()
}
results := testInlineStyles(doc)
matches := []string{}
uniqMap := make(map[string]bool)
for key := range results {
if _, exists := uniqMap[key]; !exists {
matches = append(matches, key)
}
}
// ensure results are sorted to ensure consistent results
sort.Strings(matches)
assertEqual(t, expected, strings.Join(matches, ","), fmt.Sprintf("inline style detection \"%s\"", html))
}
}
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)
}

201
internal/htmlcheck/main.go Normal file
View File

@@ -0,0 +1,201 @@
package htmlcheck
import (
"fmt"
"regexp"
"sort"
"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"
)
// RunTests will run all tests on an HTML string
func RunTests(html string) (Response, error) {
s := Response{}
s.Warnings = []Warning{}
if platforms, err := Platforms(); err == nil {
s.Platforms = platforms
}
s.Total = Total{}
// crude way to determine whether the HTML contains a <body> structure
// or whether it's just plain HTML content
re := regexp.MustCompile(`(?im)</body>`)
nodeMatch := "body *, script"
if re.MatchString(html) {
nodeMatch = "*:not(html):not(head):not(meta), script"
}
reader := strings.NewReader(html)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return s, err
}
// calculate the number of nodes in HTML
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
if err := loadJSONData(); err != nil {
return s, err
}
// HTML tests
htmlResults, totalTests, err := runHTMLTests(html)
if err != nil {
return s, err
}
s.Total.Tests = s.Total.Tests + totalTests
// add html test totals
s.Warnings = append(s.Warnings, htmlResults...)
// CSS tests
cssResults, totalTests, err := runCSSTests(html)
if err != nil {
return s, err
}
s.Total.Tests = s.Total.Tests + totalTests
// add css test totals
s.Warnings = append(s.Warnings, cssResults...)
// calculate total score
var partial, unsupported float32
partial = 0
unsupported = 0
for _, w := range s.Warnings {
if w.Score.Found == 0 {
continue
}
// supported is calculated by subtracting partial and unsupported from 100%
if w.Score.Partial > 0 {
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
if weighted > partial {
partial = weighted
}
}
if w.Score.Unsupported > 0 {
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
if weighted > unsupported {
unsupported = weighted
}
}
}
s.Total.Supported = 100 - partial - unsupported
s.Total.Partial = partial
s.Total.Unsupported = unsupported
// sort slice to get lowest scores first
sort.Slice(s.Warnings, func(i, j int) bool {
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
})
return s, nil
}
// Test returns a test
func (c CanIEmail) getTest(k string) (Warning, error) {
warning := Warning{}
exists := false
found := JSONResult{}
for _, r := range cie.Data {
if r.Slug == k {
found = r
exists = true
break
}
}
if !exists {
return warning, fmt.Errorf("%s does not exist", k)
}
warning.Slug = found.Slug
warning.Title = found.Title
warning.Description = mdToHTML(found.Description)
warning.Category = found.Category
warning.URL = found.URL
warning.Tags = found.Tags
// warning.Keywords = found.Keywords
// warning.Notes = found.Notes
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
for nr, note := range found.NotesByNumber {
warning.NotesByNumber[nr] = mdToHTML(note)
}
warning.Results = []Result{}
var y, n, p float32
for family, stats := range found.Stats {
if len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {
continue
}
for platform, clients := range stats.(map[string]interface{}) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]interface{}) {
s := Result{}
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
s.Family = family
s.Platform = platform
s.Version = version
if support == "y" {
y++
s.Support = "yes"
} else if support == "n" {
n++
s.Support = "no"
} else {
p++
s.Support = "partial"
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
for _, id := range noteIDS {
s.NoteNumber = id
}
}
warning.Results = append(warning.Results, s)
}
}
}
total := y + n + p
warning.Score.Supported = y / total * 100
warning.Score.Unsupported = n / total * 100
warning.Score.Partial = p / total * 100
return warning, nil
}
// Convert markdown to HTML, stripping <p> & </p>
func mdToHTML(str string) string {
md := []byte(str)
// create markdown parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
// extensions := parser.NoExtensions
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
}

View File

@@ -0,0 +1,42 @@
package htmlcheck
import (
"sort"
"github.com/axllent/mailpit/internal/tools"
)
// Platforms returns all platforms with their respective email clients
func Platforms() (map[string][]string, error) {
// [platform]clients
data := make(map[string][]string)
if err := loadJSONData(); err != nil {
return data, err
}
for _, t := range cie.Data {
for family, stats := range t.Stats {
niceFamily := cie.NiceNames.Family[family]
for platform := range stats.(map[string]interface{}) {
c, found := data[platform]
if !found {
data[platform] = []string{}
}
if !tools.InArray(niceFamily, c) {
c = append(c, niceFamily)
data[platform] = c
}
}
}
}
for group, clients := range data {
sort.Slice(clients, func(i, j int) bool {
return clients[i] < clients[j]
})
data[group] = clients
}
return data, nil
}

View File

@@ -0,0 +1,87 @@
package htmlcheck
// Response represents the HTML check response struct
//
// swagger:model HTMLCheckResponse
type Response struct {
// List of warnings from tests
Warnings []Warning `json:"Warnings"`
// All platforms tested, mainly for the web UI
Platforms map[string][]string `json:"Platforms"`
// Total overall score
Total Total `json:"Total"`
}
// Warning represents a failed test
//
// swagger:model HTMLCheckWarning
type Warning struct {
// Slug identifier
Slug string `json:"Slug"`
// Friendly title
Title string `json:"Title"`
// Description
Description string `json:"Description"`
// URL to caniemail.com
URL string `json:"URL"`
// Category [css, html]
Category string `json:"Category"`
// Tags
Tags []string `json:"Tags"`
// Keywords
Keywords string `json:"Keywords"`
// Test results
Results []Result `json:"Results"`
// Notes based on results
NotesByNumber map[string]string `json:"NotesByNumber"`
// Test score calculated from results
Score Score `json:"Score"`
}
// Result struct
//
// swagger:model HTMLCheckResult
type Result struct {
// Friendly name of result, combining family, platform & version
Name string `json:"Name"`
// Platform eg: ios, android, windows
Platform string `json:"Platform"`
// Family eg: Outlook, Mozilla Thunderbird
Family string `json:"Family"`
// Family version eg: 4.7.1, 2019-10, 10.3
Version string `json:"Version"`
// Support [yes, no, partial]
Support string `json:"Support"`
// Note number for partially supported if applicable
NoteNumber string `json:"NoteNumber"` // where applicable
}
// Score struct
//
// swagger:model HTMLCheckScore
type Score struct {
// Number of matches in the document
Found int `json:"Found"`
// Total percentage supported
Supported float32 `json:"Supported"`
// Total percentage partially supported
Partial float32 `json:"Partial"`
// Total percentage unsupported
Unsupported float32 `json:"Unsupported"`
}
// Total weighted result for all scores
//
// swagger:model HTMLCheckTotal
type Total struct {
// Total number of tests done
Tests int `json:"Tests"`
// Total number of HTML nodes detected in message
Nodes int `json:"Nodes"`
// Overall percentage supported
Supported float32 `json:"Supported"`
// Overall percentage partially supported
Partial float32 `json:"Partial"` // total percentage
// Overall percentage unsupported
Unsupported float32 `json:"Unsupported"` // total percentage
}

View File

@@ -0,0 +1,71 @@
package linkcheck
import (
"reflect"
"testing"
"github.com/axllent/mailpit/internal/storage"
)
var (
testHTML = `
<html>
<head>
<link rel=stylesheet href="http://remote-host/style.css"></link>
<script async src="https://www.googletagmanager.com/gtag/js?id=ignored"></script>
</head>
<body>
<div>
<p><a href="http://example.com">HTTP link</a></p>
<p><a href="https://example.com">HTTPS link</a></p>
<p><a href="HTTPS://EXAMPLE.COM">HTTPS link</a></p>
<p><a href="http://localhost">Localhost link</a> (ignored)</p>
<p><a href="https://localhost">Localhost link</a> (ignored)</p>
<p><a href='https://127.0.0.1'>Single quotes link</a> (ignored)</p>
<p><img src=https://example.com/image.jpg></p>
<p href="http://invalid-link.com">This should be ignored</p>
<p><a href="http://link with spaces">Link with spaces</a></p>
<p><a href="http://example.com/?blaah=yes&amp;test=true">URL-encoded characters</a></p>
</div>
</body>
</html>`
expectedHTMLLinks = []string{
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "https://localhost", "https://127.0.0.1", "http://link with spaces", "http://example.com/?blaah=yes&test=true",
"http://remote-host/style.css", // css
"https://example.com/image.jpg", // images
}
testTextLinks = `This is a line with http://example.com https://example.com
HTTPS://EXAMPLE.COM
[http://localhost]
www.google.com < ignored
|||http://example.com/?some=query-string|||
`
expectedTextLinks = []string{
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string",
}
)
func TestLinkDetection(t *testing.T) {
t.Log("Testing HTML link detection")
m := storage.Message{}
m.Text = testTextLinks
m.HTML = testHTML
textLinks := extractTextLinks(&m)
if !reflect.DeepEqual(textLinks, expectedTextLinks) {
t.Fatalf("Failed to detect text links correctly")
}
htmlLinks := extractHTMLLinks(&m)
if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {
t.Fatalf("Failed to detect HTML links correctly")
}
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,89 @@
// Package logger handles the logging
package logger
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
// VerboseLogging for verbose logging
VerboseLogging bool
// QuietLogging shows only errors
QuietLogging bool
// NoLogging shows only fatal errors
NoLogging bool
// LogFile sets a log file
LogFile string
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}
if LogFile != "" {
file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec
if err == nil {
log.Out = file
} else {
log.Out = os.Stdout
log.Warn("Failed to log to file, using default stderr")
}
} else {
log.Out = os.Stdout
}
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}
// CleanIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
func CleanIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "0.0.0.0:" + s[5:]
}
return s
}
// CleanHTTPIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "localhost:<port>"
func CleanHTTPIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "localhost:" + s[5:]
}
return s
}

View File

@@ -0,0 +1,84 @@
package pop3
import (
"errors"
"fmt"
"net"
"strings"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
func authUser(username, password string) bool {
return auth.POP3Credentials.Match(username, password)
}
// Send a response with debug logging
func sendResponse(c net.Conn, m string) {
fmt.Fprintf(c, "%s\r\n", m)
logger.Log().Debugf("[pop3] response: %s", m)
if strings.HasPrefix(m, "-ERR ") {
sub, _ := strings.CutPrefix(m, "-ERR ")
websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub)
}
}
// Send a response without debug logging (for data)
func sendData(c net.Conn, m string) {
fmt.Fprintf(c, "%s\r\n", m)
}
// Get the latest 100 messages
func getMessages() ([]message, error) {
messages := []message{}
list, err := storage.List(0, 0, 100)
if err != nil {
return messages, err
}
for _, m := range list {
msg := message{}
msg.ID = m.ID
msg.Size = m.Size
messages = append(messages, msg)
}
return messages, nil
}
// POP3 TOP command returns the headers, followed by the next x lines
func getTop(id string, nr int) (string, string, error) {
var header, body string
raw, err := storage.GetMessageRaw(id)
if err != nil {
return header, body, errors.New("-ERR no such message")
}
parts := strings.SplitN(string(raw), "\r\n\r\n", 2)
header = parts[0]
lines := []string{}
if nr > 0 && len(parts) == 2 {
lines = strings.SplitN(parts[1], "\r\n", nr)
}
return header, strings.Join(lines, "\r\n"), nil
}
// cuts the line into command and arguments
func getCommand(line string) (string, []string) {
line = strings.Trim(line, "\r \n")
cmd := strings.Split(line, " ")
return cmd[0], cmd[1:]
}
func getSafeArg(args []string, nr int) (string, error) {
if nr < len(args) {
return args[nr], nil
}
return "", errors.New("-ERR out of range")
}

365
internal/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
internal/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

@@ -0,0 +1,453 @@
// 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 name
Host string `json:"host"`
// Port number
Port int `json:"port"`
// DialTimeout default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
// Dialer
Dialer Dialer `json:"-"`
// TLSEnabled sets whether SLS is enabled
TLSEnabled bool `json:"tls_enabled"`
// TLSSkipVerify skips TLS verification (ie: self-signed)
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 in bytes
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

@@ -0,0 +1,121 @@
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
// See https://en.wikipedia.org/wiki/Chaos_engineering
// See https://mailpit.axllent.org/docs/integration/chaos/
package chaos
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"github.com/axllent/mailpit/internal/logger"
)
var (
// Enabled is a flag to enable or disable support for chaos
Enabled = false
// Config is the global Chaos configuration
Config = Triggers{
Sender: Trigger{ErrorCode: 451, Probability: 0},
Recipient: Trigger{ErrorCode: 451, Probability: 0},
Authentication: Trigger{ErrorCode: 535, Probability: 0},
}
)
// Triggers for the Chaos configuration
// swagger:model Triggers
type Triggers struct {
// Sender trigger to fail on From, Sender
Sender Trigger
// Recipient trigger to fail on To, Cc, Bcc
Recipient Trigger
// Authentication trigger to fail while authenticating (auth must be configured)
Authentication Trigger
}
// Trigger for Chaos
// swagger:model Trigger
type Trigger struct {
// SMTP error code to return. The value must range from 400 to 599.
// required: true
// example: 451
ErrorCode int
// Probability (chance) of triggering the error. The value must range from 0 to 100.
// required: true
// example: 5
Probability int
}
// SetFromStruct will set a whole map of chaos configurations (ie: API)
func SetFromStruct(c Triggers) error {
if c.Sender.ErrorCode == 0 {
c.Sender.ErrorCode = 451 // default
}
if c.Recipient.ErrorCode == 0 {
c.Recipient.ErrorCode = 451 // default
}
if c.Authentication.ErrorCode == 0 {
c.Authentication.ErrorCode = 535 // default
}
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
return err
}
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
return err
}
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
return err
}
return nil
}
// Set will set the chaos configuration for the given key (CLI & setMap())
func Set(key string, errorCode int, probability int) error {
Enabled = true
if errorCode < 400 || errorCode > 599 {
return fmt.Errorf("error code must be between 400 and 599")
}
if probability > 100 || probability < 0 {
return fmt.Errorf("probability must be between 0 and 100")
}
key = strings.ToLower(key)
switch key {
case "sender":
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
case "recipient", "recipients":
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
case "auth", "authentication":
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
default:
return fmt.Errorf("unknown key %s", key)
}
return nil
}
// Trigger will return whether the Chaos rule is triggered based on the configuration
// and a randomly-generated percentage value.
func (c Trigger) Trigger() (bool, int) {
if !Enabled || c.Probability == 0 {
return false, 0
}
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
// so value must be less than (not <=) to the probability to trigger
return int(nBig.Int64()) < c.Probability, c.ErrorCode
}

111
internal/smtpd/forward.go Normal file
View File

@@ -0,0 +1,111 @@
package smtpd
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
)
// Wrapper to forward messages if configured
func autoForwardMessage(from string, data *[]byte) {
if config.SMTPForwardConfig.Host == "" {
return
}
if err := forward(from, *data); err != nil {
logger.Log().Errorf("[forward] error: %s", err.Error())
} else {
logger.Log().Debugf("[forward] message from %s to %s via %s:%d",
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
}
}
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
func forward(from string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
}
defer c.Close()
if config.SMTPForwardConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPForwardConfig.Host} // #nosec
conf.InsecureSkipVerify = config.SMTPForwardConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
auth := forwardAuthFromConfig()
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if config.SMTPForwardConfig.OverrideFrom != "" {
msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("error overriding From header: %s", err.Error())
}
from = config.SMTPForwardConfig.OverrideFrom
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
to := strings.Split(config.SMTPForwardConfig.To, ",")
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())
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("error response to DATA command: %s", err.Error())
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
}
return c.Quit()
}
// Return the SMTP forwarding authentication based on config
func forwardAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPForwardConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
}
if config.SMTPForwardConfig.Auth == "login" {
a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
}
if config.SMTPForwardConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
}
return a
}

317
internal/smtpd/main.go Normal file
View File

@@ -0,0 +1,317 @@
// Package smtpd is the SMTP daemon
package smtpd
import (
"bytes"
"fmt"
"net"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/lithammer/shortuuid/v4"
)
var (
// DisableReverseDNS allows rDNS to be disabled
DisableReverseDNS bool
warningResponse = regexp.MustCompile(`^4\d\d `)
errorResponse = regexp.MustCompile(`^5\d\d `)
)
// MailHandler handles the incoming message to store in the database
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
return SaveToDatabase(origin, from, to, data)
}
// SaveToDatabase will attempt to save a message to the database
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (string, error) {
if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
stats.LogSMTPRejected()
return "", err
}
// check / set the Return-Path based on SMTP from
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath != from {
data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">")
if err != nil {
return "", err
}
}
messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>")
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = shortuuid.New() + "@mailpit"
// add unique ID
data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
stats.LogSMTPIgnored()
return "", nil
}
}
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
autoRelayMessage(from, to, &data)
// if enabled, this will forward a copy to preconfigured addresses
autoForwardMessage(from, &data)
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
missingAddresses := []string{}
for _, a := range to {
// loop through passed email addresses to check if they are in the headers
if _, err := mail.ParseAddress(a); err == nil {
_, ok := emails[strings.ToLower(a)]
if !ok {
missingAddresses = append(missingAddresses, a)
}
} else {
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
}
}
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
bccVal := strings.Join(missingAddresses, ", ")
if hasBccHeader {
b := msg.Header.Get("Bcc")
bccVal = ", " + b
}
data, err = tools.SetMessageHeader(data, "Bcc", bccVal)
if err != nil {
return "", err
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
id, err := storage.Store(&data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return "", err
}
stats.LogSMTPAccepted(len(data))
data = nil // avoid memory leaks
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return id, err
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
allow := auth.SMTPCredentials.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
}
return allow, nil
}
// Allow any username and password
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
return true, nil
}
// HandlerRcpt used to optionally restrict recipients based on `--smtp-allowed-recipients`
func handlerRcpt(remoteAddr net.Addr, from string, to string) bool {
if config.SMTPAllowedRecipientsRegexp == nil {
return true
}
result := config.SMTPAllowedRecipientsRegexp.MatchString(to)
if !result {
logger.Log().Warnf("[smtpd] rejected message to %s from %s (%s)", to, from, cleanIP(remoteAddr))
stats.LogSMTPRejected()
}
return result
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login authentication (insecure)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any authentication (insecure)")
}
} else {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login authentication")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any authentication")
}
}
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
// Translate the smtpd verb from READ/WRITE
func verbLogTranslator(verb string) string {
if verb == "READ" {
return "received"
}
return "response"
}
func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) error {
socketAddr, perm, isSocket := tools.UnixSocket(addr)
Debug = true // to enable Mailpit logging
srv := &Server{
Addr: addr,
MsgIDHandler: handler,
HandlerRcpt: handlerRcpt,
AppName: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
MaxRecipients: config.SMTPMaxRecipients,
DisableReverseDNS: DisableReverseDNS,
LogRead: func(remoteIP, verb, line string) {
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
},
LogWrite: func(remoteIP, verb, line string) {
if warningResponse.MatchString(line) {
logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
websockets.BroadCastClientError("warning", "smtpd", remoteIP, line)
} else if errorResponse.MatchString(line) {
logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
websockets.BroadCastClientError("error", "smtpd", remoteIP, line)
} else {
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
}
},
}
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}
}
if auth.SMTPCredentials != nil {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandlerAny
}
if config.SMTPTLSCert != "" {
srv.TLSRequired = config.SMTPRequireSTARTTLS
srv.TLSListener = config.SMTPRequireTLS // if true overrules srv.TLSRequired
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
return err
}
}
if isSocket {
srv.Addr = socketAddr
srv.Protocol = "unix"
srv.SocketPerm = perm
if err := tools.PrepareSocket(srv.Addr); err != nil {
storage.Close()
return err
}
// delete the Unix socket file on exit
storage.AddTempFile(srv.Addr)
logger.Log().Infof("[smtpd] starting on %s", config.SMTPListen)
} else {
smtpType := "no encryption"
if config.SMTPTLSCert != "" {
if config.SMTPRequireTLS {
smtpType = "SSL/TLS required"
} else if config.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else {
smtpType = "STARTTLS optional"
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {
smtpType = "STARTTLS required"
}
}
}
logger.Log().Infof("[smtpd] starting on %s (%s)", config.SMTPListen, smtpType)
}
return srv.ListenAndServe()
}
func cleanIP(i net.Addr) string {
parts := strings.Split(i.String(), ":")
return parts[0]
}
// Returns a list of all lowercased emails found in To, Cc and Bcc,
// as well as whether there is a Bcc field
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
emails := make(map[string]bool)
hasBccHeader := false
if recipients, err := h.AddressList("To"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
if recipients, err := h.AddressList("Cc"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
recipients, err := h.AddressList("Bcc")
if err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
hasBccHeader = true
}
return emails, hasBccHeader
}

173
internal/smtpd/relay.go Normal file
View File

@@ -0,0 +1,173 @@
package smtpd
import (
"crypto/tls"
"errors"
"fmt"
"net/smtp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
)
// Wrapper to auto relay messages if configured
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("[relay] 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 := Relay(from, to, *data); err != nil {
logger.Log().Errorf("[relay] error: %s", err.Error())
} else {
logger.Log().Debugf("[relay] sent 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 := Relay(from, filtered, *data); err != nil {
logger.Log().Errorf("[relay] error: %s", err.Error())
} else {
logger.Log().Debugf("[relay] auto-relay message to %s from %s via %s:%d",
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
}
}
}
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Relay(from string, to []string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
}
defer c.Close()
if config.SMTPRelayConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host} // #nosec
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
auth := relayAuthFromConfig()
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if config.SMTPRelayConfig.OverrideFrom != "" {
msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("error overriding From header: %s", err.Error())
}
from = config.SMTPRelayConfig.OverrideFrom
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
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())
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("error response to DATA command: %s", err.Error())
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
}
return c.Quit()
}
// Return the SMTP relay authentication based on config
func relayAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
return a
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
username, password string
}
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("Unknown fromServer")
}
}
return nil, nil
}

979
internal/smtpd/smtpd.go Normal file
View File

@@ -0,0 +1,979 @@
// Package smtpd implements a basic SMTP server.
//
// This is a modified version of https://github.com/mhale/smtpd to
// add support for unix sockets and Mailpit Chaos.
package smtpd
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"log"
"net"
"os"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/axllent/mailpit/internal/smtpd/chaos"
)
var (
// Debug `true` enables verbose logging.
Debug = false
rcptToRE = regexp.MustCompile(`(?i)TO: ?<([^<>\v]+)>( |$)(.*)?`)
mailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
// extract mail size from 'MAIL FROM' parameter
mailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`)
)
// Handler function called upon successful receipt of an email.
// Results in a "250 2.0.0 Ok: queued" response.
type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) error
// MsgIDHandler function called upon successful receipt of an email. Returns a message ID.
// Results in a "250 2.0.0 Ok: queued as <message-id>" response.
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte) (string, error)
// HandlerRcpt function called on RCPT. Return accept status.
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
// AuthHandler function called when a login attempt is performed. Returns true if credentials are correct.
type AuthHandler func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error)
// ErrServerClosed is the default message when a server closes a connection
var ErrServerClosed = errors.New("Server has been closed")
// ListenAndServe listens on the TCP network address addr
// and then calls Serve with handler to handle requests
// on incoming connections.
func ListenAndServe(addr string, handler Handler, appName string, hostname string) error {
srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}
return srv.ListenAndServe()
}
// ListenAndServeTLS listens on the TCP network address addr
// and then calls Serve with handler to handle requests
// on incoming connections. Connections may be upgraded to TLS if the client requests it.
func ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler, appName string, hostname string) error {
srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}
err := srv.ConfigureTLS(certFile, keyFile)
if err != nil {
return err
}
return srv.ListenAndServe()
}
type maxSizeExceededError struct {
limit int
}
func maxSizeExceeded(limit int) maxSizeExceededError {
return maxSizeExceededError{limit}
}
// Error uses the RFC 5321 response message in preference to RFC 1870.
// RFC 3463 defines enhanced status code x.3.4 as "Message too big for system".
func (err maxSizeExceededError) Error() string {
return fmt.Sprintf("552 5.3.4 Requested mail action aborted: exceeded storage allocation (%d)", err.limit)
}
// LogFunc is a function capable of logging the client-server communication.
type LogFunc func(remoteIP, verb, line string)
// Server is an SMTP server.
type Server struct {
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
AppName string
AuthHandler AuthHandler
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname
Handler Handler
HandlerRcpt HandlerRcpt
Hostname string
LogRead LogFunc
LogWrite LogFunc
MaxSize int // Maximum message size allowed, in bytes
MaxRecipients int // Maximum number of recipients, defaults to 100.
MsgIDHandler MsgIDHandler
Timeout time.Duration
TLSConfig *tls.Config
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
Protocol string // Default tcp, supports unix
SocketPerm fs.FileMode // if using Unix socket, socket permissions
inShutdown int32 // server was closed or shutdown
openSessions int32 // count of open sessions
mu sync.Mutex
shutdownChan chan struct{} // let the sessions know we are shutting down
XClientAllowed []string // List of XCLIENT allowed IP addresses
}
// ConfigureTLS creates a TLS configuration from certificate and key files.
func (srv *Server) ConfigureTLS(certFile string, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} // #nosec
return nil
}
// // ConfigureTLSWithPassphrase creates a TLS configuration from a certificate,
// // an encrypted key file and the associated passphrase:
// func (srv *Server) ConfigureTLSWithPassphrase(
// certFile string,
// keyFile string,
// passphrase string,
// ) error {
// certPEMBlock, err := os.ReadFile(certFile)
// if err != nil {
// return err
// }
// keyPEMBlock, err := os.ReadFile(keyFile)
// if err != nil {
// return err
// }
// keyDERBlock, _ := pem.Decode(keyPEMBlock)
// keyPEMDecrypted, err := x509.DecryptPEMBlock(keyDERBlock, []byte(passphrase))
// if err != nil {
// return err
// }
// var pemBlock pem.Block
// pemBlock.Type = keyDERBlock.Type
// pemBlock.Bytes = keyPEMDecrypted
// keyPEMBlock = pem.EncodeToMemory(&pemBlock)
// cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
// if err != nil {
// return err
// }
// srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
// return nil
// }
// ListenAndServe listens on the either a TCP network address srv.Addr or
// alternatively a Unix socket. and then calls Serve to handle requests on
// incoming connections. If srv.Addr is blank, ":25" is used.
func (srv *Server) ListenAndServe() error {
if atomic.LoadInt32(&srv.inShutdown) != 0 {
return ErrServerClosed
}
if srv.Addr == "" {
srv.Addr = ":25"
}
if srv.AppName == "" {
srv.AppName = "smtpd"
}
if srv.Hostname == "" {
srv.Hostname, _ = os.Hostname()
}
if srv.Timeout == 0 {
srv.Timeout = 5 * time.Minute
}
if srv.Protocol == "" {
srv.Protocol = "tcp"
}
var ln net.Listener
var err error
// If TLSListener is enabled, listen for TLS connections only.
if srv.TLSConfig != nil && srv.TLSListener {
ln, err = tls.Listen(srv.Protocol, srv.Addr, srv.TLSConfig)
} else {
ln, err = net.Listen(srv.Protocol, srv.Addr)
}
if err != nil {
return err
}
if srv.Protocol == "unix" {
// set permissions
if err := os.Chmod(srv.Addr, srv.SocketPerm); err != nil {
return err
}
}
return srv.Serve(ln)
}
// Serve creates a new SMTP session after a network connection is established.
func (srv *Server) Serve(ln net.Listener) error {
if atomic.LoadInt32(&srv.inShutdown) != 0 {
return ErrServerClosed
}
defer ln.Close()
for {
// if we are shutting down, don't accept new connections
select {
case <-srv.getShutdownChan():
return ErrServerClosed
default:
}
conn, err := ln.Accept()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
continue
}
return err
}
session := srv.newSession(conn)
atomic.AddInt32(&srv.openSessions, 1)
go session.serve()
}
}
type session struct {
srv *Server
conn net.Conn
br *bufio.Reader
bw *bufio.Writer
remoteIP string // Remote IP address
remoteHost string // Remote hostname according to reverse DNS lookup
remoteName string // Remote hostname as supplied with EHLO
xClient string // Information string as supplied with XCLIENT
xClientADDR string // Information string as supplied with XCLIENT ADDR
xClientNAME string // Information string as supplied with XCLIENT NAME
xClientTrust bool // Trust XCLIENT from current IP address
tls bool
authenticated bool
}
// Create new session from connection.
func (srv *Server) newSession(conn net.Conn) (s *session) {
s = &session{
srv: srv,
conn: conn,
br: bufio.NewReader(conn),
bw: bufio.NewWriter(conn),
}
// Get remote end info for the Received header.
s.remoteIP, _, _ = net.SplitHostPort(s.conn.RemoteAddr().String())
if s.remoteIP == "" {
s.remoteIP = "127.0.0.1"
}
if !s.srv.DisableReverseDNS {
names, err := net.LookupAddr(s.remoteIP)
if err == nil && len(names) > 0 {
s.remoteHost = names[0]
} else {
s.remoteHost = "unknown"
}
} else {
s.remoteHost = "unknown"
}
// Set tls = true if TLS is already in use.
_, s.tls = s.conn.(*tls.Conn)
for _, checkIP := range srv.XClientAllowed {
if s.remoteIP == checkIP {
s.xClientTrust = true
}
}
return
}
func (srv *Server) getShutdownChan() <-chan struct{} {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.shutdownChan == nil {
srv.shutdownChan = make(chan struct{})
}
return srv.shutdownChan
}
func (srv *Server) closeShutdownChan() {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.shutdownChan == nil {
srv.shutdownChan = make(chan struct{})
}
select {
case <-srv.shutdownChan:
default:
close(srv.shutdownChan)
}
}
// Close - closes the connection without waiting
func (srv *Server) Close() error {
atomic.StoreInt32(&srv.inShutdown, 1)
srv.closeShutdownChan()
return nil
}
// Shutdown - waits for current sessions to complete before closing
func (srv *Server) Shutdown(ctx context.Context) error {
atomic.StoreInt32(&srv.inShutdown, 1)
srv.closeShutdownChan()
// wait for up to 30 seconds to allow the current sessions to
// end
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for i := 0; i < 300; i++ {
// wait for open sessions to close
if atomic.LoadInt32(&srv.openSessions) == 0 {
break
}
select {
case <-timer.C:
timer.Reset(100 * time.Millisecond)
case <-ctx.Done():
return ctx.Err()
default:
}
}
return nil
}
// Function called to handle connection requests.
func (s *session) serve() {
defer atomic.AddInt32(&s.srv.openSessions, -1)
defer s.conn.Close()
var from string
var gotFrom bool
var to []string
var buffer bytes.Buffer
// RFC 5321 specifies support for minimum of 100 recipients is required.
if s.srv.MaxRecipients == 0 {
s.srv.MaxRecipients = 100
}
// Send banner.
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName)
loop:
for {
// Attempt to read a line from the socket.
// On timeout, send a timeout message and return from serve().
// On error, assume the client has gone away i.e. return from serve().
line, err := s.readLine()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
}
break
}
verb, args := s.parseLine(line)
switch verb {
case "HELO":
s.remoteName = args
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
from = ""
gotFrom = false
to = nil
buffer.Reset()
case "EHLO":
s.remoteName = args
s.writef("%s", s.makeEHLOResponse())
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
from = ""
gotFrom = false
to = nil
buffer.Reset()
case "MAIL":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
s.writef("530 5.7.0 Must issue a STARTTLS command first")
break
}
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
s.writef("530 5.7.0 Authentication required")
break
}
match := mailFromRE.FindStringSubmatch(args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Sender.Trigger(); fail {
s.writef("%d Chaos sender error", code)
break
}
// Validate the SIZE parameter if one was sent.
if len(match[2]) > 0 { // A parameter is present
sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])
if sizeMatch == nil {
// ignore other parameter
from = match[1]
gotFrom = true
s.writef("250 2.1.0 Ok")
} else {
// Enforce the maximum message size if one is set.
size, err := strconv.Atoi(sizeMatch[2])
if err != nil { // Bad SIZE parameter
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set
err = maxSizeExceeded(s.srv.MaxSize)
s.writef("%s", err.Error())
} else { // SIZE ok
from = match[1]
gotFrom = true
s.writef("250 2.1.0 Ok")
}
}
} else { // No parameters after FROM
from = match[1]
gotFrom = true
s.writef("250 2.1.0 Ok")
}
}
to = nil
buffer.Reset()
case "RCPT":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
s.writef("530 5.7.0 Must issue a STARTTLS command first")
break
}
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
s.writef("530 5.7.0 Authentication required")
break
}
if !gotFrom {
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
break
}
match := rcptToRE.FindStringSubmatch(args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Recipient.Trigger(); fail {
s.writef("%d Chaos recipient error", code)
break
}
if len(to) >= s.srv.MaxRecipients {
s.writef("452 4.5.3 Too many recipients")
} else {
accept := true
if s.srv.HandlerRcpt != nil {
accept = s.srv.HandlerRcpt(s.conn.RemoteAddr(), from, match[1])
}
if accept {
to = append(to, match[1])
s.writef("250 2.1.5 Ok")
} else {
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
}
}
}
case "DATA":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
s.writef("530 5.7.0 Must issue a STARTTLS command first")
break
}
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
s.writef("530 5.7.0 Authentication required")
break
}
if !gotFrom || len(to) == 0 {
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
break
}
s.writef("354 Start mail input; end with <CR><LF>.<CR><LF>")
// Attempt to read message body from the socket.
// On timeout, send a timeout message and return from serve().
// On net.Error, assume the client has gone away i.e. return from serve().
// On other errors, allow the client to try again.
data, err := s.readData()
if err != nil {
switch err.(type) {
case net.Error:
if err.(net.Error).Timeout() {
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
}
break loop
case maxSizeExceededError:
s.writef("%s", err.Error())
continue
default:
s.writef("451 4.3.0 Requested action aborted: local error in processing")
continue
}
}
// Create Received header & write message body into buffer.
buffer.Reset()
buffer.Write(s.makeHeaders(to))
buffer.Write(data)
// Pass mail on to handler.
if s.srv.Handler != nil {
err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
if err != nil {
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
if checkErrFormat.MatchString(err.Error()) {
s.writef("%s", err.Error())
} else {
s.writef("451 4.3.5 Unable to process mail")
}
break
}
s.writef("250 2.0.0 Ok: queued")
} else if s.srv.MsgIDHandler != nil {
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
if err != nil {
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
if checkErrFormat.MatchString(err.Error()) {
s.writef("%s", err.Error())
} else {
s.writef("451 4.3.5 Unable to process mail")
}
break
}
if msgID != "" {
s.writef("250 2.0.0 Ok: queued as %s", msgID)
} else {
s.writef("250 2.0.0 Ok: queued")
}
} else {
s.writef("250 2.0.0 Ok: queued")
}
// Reset for next mail.
from = ""
gotFrom = false
to = nil
buffer.Reset()
case "QUIT":
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName)
break loop
case "RSET":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
s.writef("530 5.7.0 Must issue a STARTTLS command first")
break
}
s.writef("250 2.0.0 Ok")
from = ""
gotFrom = false
to = nil
buffer.Reset()
case "NOOP":
s.writef("250 2.0.0 Ok")
case "XCLIENT":
s.xClient = args
if s.xClientTrust {
xCArgs := strings.Split(args, " ")
for _, xCArg := range xCArgs {
xCParse := strings.Split(strings.TrimSpace(xCArg), "=")
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
s.xClientADDR = xCParse[1]
}
if strings.ToUpper(xCParse[0]) == "NAME" && len(xCParse[1]) > 0 {
if xCParse[1] != "[UNAVAILABLE]" {
s.xClientNAME = xCParse[1]
}
}
}
if len(s.xClientADDR) > 7 {
s.remoteIP = s.xClientADDR
if len(s.xClientNAME) > 4 {
s.remoteHost = s.xClientNAME
} else {
names, err := net.LookupAddr(s.remoteIP)
if err == nil && len(names) > 0 {
s.remoteHost = names[0]
} else {
s.remoteHost = "unknown"
}
}
}
}
s.writef("250 2.0.0 Ok")
case "HELP", "VRFY", "EXPN":
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
s.writef("502 5.5.1 Command not implemented")
case "STARTTLS":
// Parameters are not allowed (RFC 3207 section 4).
if args != "" {
s.writef("501 5.5.2 Syntax error (no parameters allowed)")
break
}
// Handle case where TLS is requested but not configured (and therefore not listed as a service extension).
if s.srv.TLSConfig == nil {
s.writef("502 5.5.1 Command not implemented")
break
}
// Handle case where STARTTLS is received when TLS is already in use.
if s.tls {
s.writef("503 5.5.1 Bad sequence of commands (TLS already in use)")
break
}
s.writef("220 2.0.0 Ready to start TLS")
// Establish a TLS connection with the client.
tlsConn := tls.Server(s.conn, s.srv.TLSConfig)
err := tlsConn.Handshake()
if err != nil {
s.writef("403 4.7.0 TLS handshake failed")
break
}
// TLS handshake succeeded, switch to using the TLS connection.
s.conn = tlsConn
s.br = bufio.NewReader(s.conn)
s.bw = bufio.NewWriter(s.conn)
s.tls = true
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
s.remoteName = ""
from = ""
gotFrom = false
to = nil
buffer.Reset()
case "AUTH":
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
s.writef("530 5.7.0 Must issue a STARTTLS command first")
break
}
// Handle case where AUTH is requested but not configured (and therefore not listed as a service extension).
if s.srv.AuthHandler == nil {
s.writef("502 5.5.1 Command not implemented")
break
}
// Handle case where AUTH is received when already authenticated.
if s.authenticated {
s.writef("503 5.5.1 Bad sequence of commands (already authenticated for this session)")
break
}
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
if gotFrom || len(to) > 0 {
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
break
}
// RFC 4954 requires a mechanism parameter.
authType, authArgs := s.parseLine(args)
if authType == "" {
s.writef("501 5.5.4 Malformed AUTH input (argument required)")
break
}
// RFC 4954 requires rejecting unsupported authentication mechanisms with a 504 response.
allowedAuth := s.authMechs()
if allowed, found := allowedAuth[authType]; !found || !allowed {
s.writef("504 5.5.4 Unrecognized authentication type")
break
}
// Mailpit Chaos
if fail, code := chaos.Config.Authentication.Trigger(); fail {
s.writef("%d Chaos authentication error", code)
break
}
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
// when attempting to use an unsupported authentication type.
// Many servers return 5.7.4 ("Security features not supported") instead.
switch authType {
case "PLAIN":
s.authenticated, err = s.handleAuthPlain(authArgs)
case "LOGIN":
s.authenticated, err = s.handleAuthLogin(authArgs)
case "CRAM-MD5":
s.authenticated, err = s.handleAuthCramMD5()
}
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
break loop
}
s.writef("%s", err.Error())
break
}
if s.authenticated {
s.writef("235 2.7.0 Authentication successful")
} else {
s.writef("535 5.7.8 Authentication credentials invalid")
}
default:
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
s.writef("500 5.5.2 Syntax error, command unrecognized")
}
}
}
// Wrapper function for writing a complete line to the socket.
func (s *session) writef(format string, args ...interface{}) {
if s.srv.Timeout > 0 {
_ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))
}
line := fmt.Sprintf(format, args...)
fmt.Fprintf(s.bw, "%s\r\n", line)
_ = s.bw.Flush()
if Debug {
verb := "WROTE"
if s.srv.LogWrite != nil {
s.srv.LogWrite(s.remoteIP, verb, line)
} else {
log.Println(s.remoteIP, verb, line)
}
}
}
// Read a complete line from the socket.
func (s *session) readLine() (string, error) {
if s.srv.Timeout > 0 {
_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
}
line, err := s.br.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line) // Strip trailing \r\n
if Debug {
verb := "READ"
if s.srv.LogRead != nil {
s.srv.LogRead(s.remoteIP, verb, line)
} else {
log.Println(s.remoteIP, verb, line)
}
}
return line, err
}
// Parse a line read from the socket.
func (s *session) parseLine(line string) (verb string, args string) {
if idx := strings.Index(line, " "); idx != -1 {
verb = strings.ToUpper(line[:idx])
args = strings.TrimSpace(line[idx+1:])
} else {
verb = strings.ToUpper(line)
args = ""
}
return verb, args
}
// Read the message data following a DATA command.
func (s *session) readData() ([]byte, error) {
var data []byte
for {
if s.srv.Timeout > 0 {
_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
}
line, err := s.br.ReadBytes('\n')
if err != nil {
return nil, err
}
// Handle end of data denoted by lone period (\r\n.\r\n)
if bytes.Equal(line, []byte(".\r\n")) {
break
}
// Remove leading period (RFC 5321 section 4.5.2)
if line[0] == '.' {
line = line[1:]
}
// Enforce the maximum message size limit.
if s.srv.MaxSize > 0 {
if len(data)+len(line) > s.srv.MaxSize {
_, _ = s.br.Discard(s.br.Buffered()) // Discard the buffer remnants.
return nil, maxSizeExceeded(s.srv.MaxSize)
}
}
data = append(data, line...)
}
return data, nil
}
// Create the Received header to comply with RFC 2821 section 3.8.2.
// TODO: Work out what to do with multiple to addresses.
func (s *session) makeHeaders(to []string) []byte {
var buffer bytes.Buffer
now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)")
buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP))
buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName))
buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now))
return buffer.Bytes()
}
// Determine allowed authentication mechanisms.
// RFC 4954 specifies that plaintext authentication mechanisms such as LOGIN and PLAIN require a TLS connection.
// This can be explicitly overridden e.g. setting s.srv.AuthMechs["LOGIN"] = true.
func (s *session) authMechs() (mechs map[string]bool) {
mechs = map[string]bool{"LOGIN": s.tls, "PLAIN": s.tls, "CRAM-MD5": true}
for mech := range mechs {
allowed, found := s.srv.AuthMechs[mech]
if found {
mechs[mech] = allowed
}
}
return
}
// Create the greeting string sent in response to an EHLO command.
func (s *session) makeEHLOResponse() (response string) {
response = fmt.Sprintf("250-%s greets %s\r\n", s.srv.Hostname, s.remoteName)
// RFC 1870 specifies that "SIZE 0" indicates no maximum size is in force.
response += fmt.Sprintf("250-SIZE %d\r\n", s.srv.MaxSize)
// Only list STARTTLS if TLS is configured, but not currently in use.
if s.srv.TLSConfig != nil && !s.tls {
response += "250-STARTTLS\r\n"
}
// Only list AUTH if an AuthHandler is configured and at least one mechanism is allowed.
if s.srv.AuthHandler != nil {
var mechs []string
for mech, allowed := range s.authMechs() {
if allowed {
mechs = append(mechs, mech)
}
}
if len(mechs) > 0 {
response += "250-AUTH " + strings.Join(mechs, " ") + "\r\n"
}
}
response += "250 ENHANCEDSTATUSCODES"
return
}
func (s *session) handleAuthLogin(arg string) (bool, error) {
var err error
if arg == "" {
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Username:")))
arg, err = s.readLine()
if err != nil {
return false, err
}
}
username, err := base64.StdEncoding.DecodeString(arg)
if err != nil {
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
}
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Password:")))
line, err := s.readLine()
if err != nil {
return false, err
}
password, err := base64.StdEncoding.DecodeString(line)
if err != nil {
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
}
// Validate credentials.
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
return authenticated, err
}
func (s *session) handleAuthPlain(arg string) (bool, error) {
var err error
// If fast mode (AUTH PLAIN [arg]) is not used, prompt for credentials.
if arg == "" {
s.writef("334 ")
arg, err = s.readLine()
if err != nil {
return false, err
}
}
data, err := base64.StdEncoding.DecodeString(arg)
if err != nil {
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
}
parts := bytes.Split(data, []byte{0})
if len(parts) != 3 {
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
}
// Validate credentials.
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
return authenticated, err
}
func (s *session) handleAuthCramMD5() (bool, error) {
shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">"
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte(shared)))
data, err := s.readLine()
if err != nil {
return false, err
}
if data == "*" {
return false, errors.New("501 5.7.0 Authentication cancelled")
}
buf, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
}
fields := strings.Split(string(buf), " ")
if len(fields) < 2 {
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
}
// Validate credentials.
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "CRAM-MD5", []byte(fields[0]), []byte(fields[1]), []byte(shared))
return authenticated, err
}

1599
internal/smtpd/smtpd_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
// Package postmark uses the free https://spamcheck.postmarkapp.com/
// See https://spamcheck.postmarkapp.com/doc/ for more details.
package postmark
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
)
// Response struct
type Response struct {
Success bool `json:"success"`
Message string `json:"message"` // for errors only
Score string `json:"score"`
Rules []Rule `json:"rules"`
Report string `json:"report"` // ignored
}
// Rule struct
type Rule struct {
Score string `json:"score"`
// Name not returned by postmark but rather extracted from description
Name string `json:"name"`
Description string `json:"description"`
}
// Check will post the email data to Postmark
func Check(email []byte, timeout int) (Response, error) {
r := Response{}
// '{"email":"raw dump of email", "options":"short"}'
var d struct {
// The raw dump of the email to be filtered, including all headers.
Email string `json:"email"`
// Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request.
Options string `json:"options"`
}
d.Email = string(email)
d.Options = "long"
data, err := json.Marshal(d)
if err != nil {
return r, err
}
client := http.Client{
Timeout: time.Duration(timeout) * time.Second,
}
resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json",
bytes.NewBuffer(data))
if err != nil {
return r, err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&r)
// remove trailing line spaces for all lines in report
re := regexp.MustCompile("\r?\n")
lines := re.Split(r.Report, -1)
reportLines := []string{}
for _, l := range lines {
line := strings.TrimRight(l, " ")
reportLines = append(reportLines, line)
}
reportRaw := strings.Join(reportLines, "\n")
// join description lines to make a single line per rule
re2 := regexp.MustCompile("\n ")
report := re2.ReplaceAllString(reportRaw, "")
for i, rule := range r.Rules {
// populate rule name
r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report)
}
return r, err
}
// Extract the name of the test from the report as Postmark does not include this in the JSON reports
func nameFromReport(score, description, report string) string {
score = regexp.QuoteMeta(score)
description = regexp.QuoteMeta(description)
str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description)
re := regexp.MustCompile(str)
matches := re.FindAllStringSubmatch(report, 1)
if len(matches) > 0 && len(matches[0]) == 2 {
return strings.TrimSpace(matches[0][1])
}
return ""
}

View File

@@ -0,0 +1,147 @@
// Package spamassassin will return results from either a SpamAssassin server or
// Postmark's public API depending on configuration
package spamassassin
import (
"errors"
"math"
"strconv"
"strings"
"github.com/axllent/mailpit/internal/spamassassin/postmark"
"github.com/axllent/mailpit/internal/spamassassin/spamc"
)
var (
// Service to use, either "<host>:<ip>" for self-hosted SpamAssassin or "postmark"
service string
// SpamScore is the score at which a message is determined to be spam
spamScore = 5.0
// Timeout in seconds
timeout = 8
)
// Result is a SpamAssassin result
//
// swagger:model SpamAssassinResponse
type Result struct {
// Whether the message is spam or not
IsSpam bool
// If populated will return an error string
Error string
// Total spam score based on triggered rules
Score float64
// Spam rules triggered
Rules []Rule
}
// Rule struct
type Rule struct {
// Spam rule score
Score float64
// SpamAssassin rule name
Name string
// SpamAssassin rule description
Description string
}
// SetService defines which service should be used.
func SetService(s string) {
switch s {
case "postmark":
service = "postmark"
default:
service = s
}
}
// SetTimeout defines the timeout
func SetTimeout(t int) {
if t > 0 {
timeout = t
}
}
// Ping returns whether a service is active or not
func Ping() error {
if service == "postmark" {
return nil
}
var client *spamc.Client
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
return client.Ping()
}
// Check will return a Result
func Check(msg []byte) (Result, error) {
r := Result{Score: 0}
if service == "" {
return r, errors.New("no SpamAssassin service defined")
}
if service == "postmark" {
res, err := postmark.Check(msg, timeout)
if err != nil {
r.Error = err.Error()
return r, nil
}
resFloat, err := strconv.ParseFloat(res.Score, 32)
if err == nil {
r.Score = round1dm(resFloat)
r.IsSpam = resFloat >= spamScore
}
r.Error = res.Message
for _, pr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(pr.Score, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = pr.Name
rule.Description = pr.Description
r.Rules = append(r.Rules, rule)
}
} else {
var client *spamc.Client
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
res, err := client.Report(msg)
if err != nil {
r.Error = err.Error()
return r, nil
}
r.IsSpam = res.Score >= spamScore
r.Score = round1dm(res.Score)
r.Rules = []Rule{}
for _, sr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(sr.Points, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = sr.Name
rule.Description = sr.Description
r.Rules = append(r.Rules, rule)
}
}
return r, nil
}
// Round to one decimal place
func round1dm(n float64) float64 {
return math.Floor(n*10) / 10
}

View File

@@ -0,0 +1,250 @@
// Package spamc provides a client for the SpamAssassin spamd protocol.
// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL
//
// Modified to add timeouts from https://github.com/cgt/spamc
package spamc
import (
"bufio"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/internal/tools"
)
// ProtoVersion is the protocol version
const ProtoVersion = "1.5"
var (
spamInfoRe = regexp.MustCompile(`(.+)\/(.+) (\d+) (.+)`)
spamMainRe = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`)
spamDetailsRe = regexp.MustCompile(`^\s?(-?[0-9\.]+)\s([a-zA-Z0-9_]*)(\W*)(.*)`)
)
// connection is like net.Conn except that it also has a CloseWrite method.
// CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some
// reason it is not present in the net.Conn interface.
type connection interface {
net.Conn
CloseWrite() error
}
// Client is a spamd client.
type Client struct {
net string
addr string
timeout int
}
// NewTCP returns a *Client that connects to spamd via the given TCP address.
func NewTCP(addr string, timeout int) *Client {
return &Client{"tcp", addr, timeout}
}
// NewUnix returns a *Client that connects to spamd via the given Unix socket.
func NewUnix(addr string) *Client {
return &Client{"unix", addr, 0}
}
// Rule represents a matched SpamAssassin rule.
type Rule struct {
Points string
Name string
Description string
}
// Result struct
type Result struct {
ResponseCode int
Message string
Spam bool
Score float64
Threshold float64
Rules []Rule
}
// dial connects to spamd through TCP or a Unix socket.
func (c *Client) dial() (connection, error) {
if c.net == "tcp" {
tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr)
if err != nil {
return nil, err
}
return net.DialTCP("tcp", nil, tcpAddr)
} else if c.net == "unix" {
unixAddr, err := net.ResolveUnixAddr("unix", c.addr)
if err != nil {
return nil, err
}
return net.DialUnix("unix", nil, unixAddr)
}
panic("Client.net must be either \"tcp\" or \"unix\"")
}
// Report checks if message is spam or not, and returns score plus report
func (c *Client) Report(email []byte) (Result, error) {
output, err := c.report(email)
if err != nil {
return Result{}, err
}
return c.parseOutput(output), nil
}
func (c *Client) report(email []byte) ([]string, error) {
conn, err := c.dial()
if err != nil {
return nil, err
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
return nil, err
}
bw := bufio.NewWriter(conn)
if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil {
return nil, err
}
if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil {
return nil, err
}
if _, err := bw.Write(email); err != nil {
return nil, err
}
if err := bw.Flush(); err != nil {
return nil, err
}
// Client is supposed to close its writing side of the connection
// after sending its request.
if err := conn.CloseWrite(); err != nil {
return nil, err
}
var (
lines []string
br = bufio.NewReader(conn)
)
for {
line, err := br.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
line = strings.TrimRight(line, " \t\r\n")
lines = append(lines, line)
}
// join lines, and replace multi-line descriptions with single line for each
tmp := strings.Join(lines, "\n")
re := regexp.MustCompile("\n ")
n := re.ReplaceAllString(tmp, " ")
//split lines again
return strings.Split(n, "\n"), nil
}
func (c *Client) parseOutput(output []string) Result {
var result Result
var reachedRules bool
for _, row := range output {
// header
if spamInfoRe.MatchString(row) {
res := spamInfoRe.FindStringSubmatch(row)
if len(res) == 5 {
resCode, err := strconv.Atoi(res[3])
if err == nil {
result.ResponseCode = resCode
}
result.Message = res[4]
continue
}
}
// summary
if spamMainRe.MatchString(row) {
res := spamMainRe.FindStringSubmatch(row)
if len(res) == 4 {
if tools.InArray(res[1], []string{"true", "yes"}) {
result.Spam = true
} else {
result.Spam = false
}
resFloat, err := strconv.ParseFloat(res[2], 32)
if err == nil {
result.Score = resFloat
continue
}
resFloat, err = strconv.ParseFloat(res[3], 32)
if err == nil {
result.Threshold = resFloat
continue
}
}
}
if strings.HasPrefix(row, "Content analysis details") {
reachedRules = true
continue
}
// details
if reachedRules && spamDetailsRe.MatchString(row) {
res := spamDetailsRe.FindStringSubmatch(row)
if len(res) == 5 {
rule := Rule{Points: res[1], Name: res[2], Description: res[4]}
result.Rules = append(result.Rules, rule)
}
}
}
return result
}
// Ping the spamd
func (c *Client) Ping() error {
conn, err := c.dial()
if err != nil {
return err
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
return err
}
if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil {
return err
}
if err := conn.CloseWrite(); err != nil {
return err
}
br := bufio.NewReader(conn)
for {
_, err = br.ReadSlice('\n')
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}

131
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,131 @@
// Package stats stores and returns Mailpit statistics
package stats
import (
"runtime"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/updater"
)
var (
// to prevent hammering Github for latest version
latestVersionCache string
// StartedAt is set to the current ime when Mailpit starts
startedAt time.Time
mu sync.RWMutex
smtpAccepted float64
smtpAcceptedSize float64
smtpRejected float64
smtpIgnored float64
)
// AppInformation struct
// swagger:model AppInformation
type AppInformation struct {
// Current Mailpit version
Version string
// Latest Mailpit version
LatestVersion string
// Database path
Database string
// Database size in bytes
DatabaseSize float64
// Total number of messages in the database
Messages float64
// Total number of messages in the database
Unread float64
// Tags and message totals per tag
Tags map[string]int64
// Runtime statistics
RuntimeStats struct {
// Mailpit server uptime in seconds
Uptime float64
// Current memory usage in bytes
Memory uint64
// Database runtime messages deleted
MessagesDeleted float64
// Accepted runtime SMTP messages
SMTPAccepted float64
// Total runtime accepted messages size in bytes
SMTPAcceptedSize float64
// Rejected runtime SMTP messages
SMTPRejected float64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
SMTPIgnored float64
}
}
// Load the current statistics
func Load() AppInformation {
info := AppInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
info.RuntimeStats.SMTPAccepted = smtpAccepted
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
info.RuntimeStats.SMTPRejected = smtpRejected
info.RuntimeStats.SMTPIgnored = smtpIgnored
if latestVersionCache != "" {
info.LatestVersion = latestVersionCache
} else {
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
latestVersionCache = latest
// clear latest version cache after 5 minutes
go func() {
time.Sleep(5 * time.Minute)
latestVersionCache = ""
}()
}
}
info.Database = config.Database
info.DatabaseSize = storage.DbSize()
info.Messages = storage.CountTotal()
info.Unread = storage.CountUnread()
info.Tags = storage.GetAllTagsCount()
return info
}
// Track will start the statistics logging in memory
func Track() {
startedAt = time.Now()
}
// LogSMTPAccepted logs a successful SMTP transaction
func LogSMTPAccepted(size int) {
mu.Lock()
smtpAccepted = smtpAccepted + 1
smtpAcceptedSize = smtpAcceptedSize + float64(size)
mu.Unlock()
}
// LogSMTPRejected logs a rejected SMTP transaction
func LogSMTPRejected() {
mu.Lock()
smtpRejected = smtpRejected + 1
mu.Unlock()
}
// LogSMTPIgnored logs an ignored SMTP transaction
func LogSMTPIgnored() {
mu.Lock()
smtpIgnored = smtpIgnored + 1
mu.Unlock()
}

216
internal/storage/cron.go Normal file
View File

@@ -0,0 +1,216 @@
package storage
import (
"context"
"database/sql"
"math"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
// Database cron runs every minute
func dbCron() {
for {
time.Sleep(60 * time.Second)
currentTime := time.Now()
sinceLastDbAction := currentTime.Sub(dbLastAction)
// only run the database has been idle for 5 minutes
if math.Floor(sinceLastDbAction.Minutes()) == 5 {
deletedSize := getDeletedSize()
if deletedSize > 0 {
total := totalMessagesSize()
var deletedPercent float64
if total == 0 {
deletedPercent = 100
} else {
deletedPercent = deletedSize * 100 / total
}
// 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 %f%% of total size, reclaim space", deletedPercent)
vacuumDb()
}
}
}
pruneMessages()
}
}
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
// Set config.MaxMessages to 0 to disable.
func pruneMessages() {
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
return
}
start := time.Now()
ids := []string{}
var prunedSize int64
var size float64
// prune using `--max` if set
if config.MaxMessages > 0 {
total := CountTotal()
if total > float64(config.MaxAgeInHours) {
offset := config.MaxMessages
if config.DemoMode {
offset = 500
}
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(offset)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}
}
// prune using `--max-age` if set
if config.MaxAgeInHours > 0 {
// now() minus the number of hours
ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
Where("Created < ?", ts).
Limit(5000)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if !tools.InArray(id, ids) {
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}
if len(ids) == 0 {
return
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
_, 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.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.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
}
err = tx.Commit()
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
if err := pruneUnusedTags(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
addDeletedSize(prunedSize)
dbLastAction = time.Now()
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
logMessagesDeleted(len(ids))
if config.DemoMode {
vacuumDb()
}
websockets.Broadcast("prune", nil)
}
// 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
if _, err := db.Exec("PRAGMA wal_checkpoint"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
// vacuum database
if _, err := db.Exec("VACUUM"); err != nil {
logger.Log().Errorf("[db] VACUUM: %s", err.Error())
return
}
// truncate WAL file
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] vacuum completed in %s", elapsed)
}

View File

@@ -0,0 +1,284 @@
// Package storage handles all database actions
package storage
import (
"context"
"database/sql"
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
// 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
sqlDriver string
dbLastAction time.Time
// zstd compression encoder & decoder
dbEncoder *zstd.Encoder
dbDecoder, _ = zstd.NewReader(nil)
temporaryFiles = []string{}
)
// InitDB will initialise the database
func InitDB() error {
// dbEncoder
var (
dsn string
err error
)
if config.Compression > 0 {
var compression zstd.EncoderLevel
switch config.Compression {
case 1:
compression = zstd.SpeedFastest
case 2:
compression = zstd.SpeedDefault
case 3:
compression = zstd.SpeedBestCompression
}
dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))
if err != nil {
return err
}
logger.Log().Debugf("[db] storing messages with compression: %s", compression.String())
} else {
logger.Log().Debug("[db] storing messages with no compression")
}
p := config.Database
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())
// delete the Unix socket file on exit
AddTempFile(p)
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.Database = 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()
}
}
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)
if sqlDriver == "sqlite" {
if config.DisableWAL {
// disable WAL mode for SQLite, allows NFS mounted DBs
_, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;")
} else {
// 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 := dbApplySchemas(); err != nil {
return err
}
LoadTagFilters()
dbFile = p
dbLastAction = time.Now()
sigs := make(chan os.Signal, 1)
// catch all signals since not explicitly listing
// Program that will listen to the SIGINT and SIGTERM
// SIGINT will listen to CTRL-C.
// SIGTERM will be caught if kill command executed
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
// method invoked upon seeing signal
go func() {
s := <-sigs
fmt.Printf("[db] got %s signal, shutting down\n", s)
Close()
os.Exit(0)
}()
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
// 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)
// delete all temporary files
deleteTempFiles()
}
// 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 (
total = CountTotal()
unread = CountUnread()
tags = GetAllTags()
)
dbLastAction = time.Now()
return MailboxStats{
Total: total,
Unread: unread,
Tags: tags,
}
}
// CountTotal returns the number of emails in the database
func CountTotal() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
return total
}
// CountUnread returns the number of emails in the database that are unread.
func CountUnread() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 0).
QueryRowAndClose(context.TODO(), db)
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 1).
QueryRowAndClose(context.TODO(), db)
return total
}
// 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
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id).
QueryRowAndClose(context.TODO(), db)
return unread == 1
}
// MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool {
var total int
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("MessageID = ?", id).
QueryRowAndClose(context.TODO(), db)
return total != 0
}

View File

@@ -0,0 +1,751 @@
package storage
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
"github.com/lithammer/shortuuid/v4"
)
// Store will save an email to the database tables.
// Returns the database ID of the saved message.
func Store(body *[]byte) (string, error) {
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
// Parse message body with enmime
env, err := parser.ReadEnvelope(bytes.NewReader(*body))
if err != nil {
logger.Log().Warnf("[message] %s", err.Error())
return "", nil
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := DBMailSummary{
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
created := time.Now()
// use message date instead of created date
if config.UseMessageDates {
if mDate, err := env.Date(); err == nil {
created = mDate
}
}
// generate the search text
searchText := createSearchText(env)
// generate unique ID
id := shortuuid.New()
summaryJSON, err := json.Marshal(obj)
if err != nil {
return "", err
}
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
// roll back if it fails
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := 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(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
if err != nil {
return "", err
}
if config.Compression > 0 {
// insert compressed raw message
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
if sqlDriver == "rqlite" {
// rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal
// string and then generate the SQL query, which is more memory intensive, especially with large messages
hexStr := hex.EncodeToString(compressed)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant("mailbox_data"), hexStr), id) // #nosec
} else {
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec
}
compressed = nil
} else {
// insert uncompressed raw message
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant("mailbox_data")), id, string(*body)) // #nosec
}
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
if !config.TagsDisableXTags {
xTagsHdr := env.GetHeader("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
}
}
c := &MessageSummary{}
if err := json.Unmarshal(summaryJSON, c); err != nil {
return "", err
}
c.Created = created
c.ID = id
c.MessageID = messageID
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = setTags
c.Snippet = snippet
websockets.Broadcast("new", c)
webhook.Send(c)
dbLastAction = time.Now()
BroadcastMailboxStats()
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
return id, nil
}
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
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")
if limit > 0 {
q = q.Limit(limit).Offset(start)
}
if beforeTS > 0 {
q = q.Where("Created < ?", beforeTS)
}
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 float64
var attachments int
var read int
var snippet string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
// artificially generate ReplyTo if legacy data is missing Reply-To field
if em.ReplyTo == nil {
em.ReplyTo = []*mail.Address{}
}
results = append(results, em)
}); err != nil {
return results, err
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
}
dbLastAction = time.Now()
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] list INBOX in %s", elapsed)
return results, nil
}
// GetMessage returns a Message generated from the mailbox_data collection.
// If the message lacks a date header, then the received datetime is used.
func GetMessage(id string) (*Message, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From(tenant("mailbox")).
Select(`Created`).
Where(`ID = ?`, id)
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())
return
}
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(int64(created))
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
obj := Message{
ID: id,
MessageID: messageID,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: float64(len(raw)),
Text: env.Text,
}
obj.HTML = env.HTML
obj.Inline = []Attachment{}
obj.Attachments = []Attachment{}
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
}
}
// get List-Unsubscribe links if set
obj.ListUnsubscribe = ListUnsubscribe{}
obj.ListUnsubscribe.Links = []string{}
if env.GetHeader("List-Unsubscribe") != "" {
l := env.GetHeader("List-Unsubscribe")
links, err := tools.ListUnsubscribeParser(l)
obj.ListUnsubscribe.Header = l
obj.ListUnsubscribe.Links = links
if err != nil {
obj.ListUnsubscribe.Errors = err.Error()
}
obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post")
}
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
}
dbLastAction = time.Now()
return &obj, nil
}
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i, msg string
var compressed int
q := sqlf.From(tenant("mailbox_data")).
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Select(`Compressed`).To(&compressed).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return nil, err
}
if i == "" {
return nil, errors.New("message not found")
}
var data []byte
if sqlDriver == "rqlite" && compressed == 1 {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
}
} else {
data = []byte(msg)
}
dbLastAction = time.Now()
if compressed == 1 {
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
return raw, err
}
return data, nil
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
for _, a := range env.Inlines {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.OtherParts {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.Attachments {
if a.PartID == partID {
return a, nil
}
}
dbLastAction = time.Now()
return nil, errors.New("attachment not found")
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = float64(len(a.Content))
return o
}
// LatestID returns the latest message ID
//
// If a query argument is set in the request the function will return the
// latest message matching the search
func LatestID(r *http.Request) (string, error) {
var messages []MessageSummary
var err error
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
if err != nil {
return "", err
}
} else {
messages, err = List(0, 0, 1)
if err != nil {
return "", err
}
}
if len(messages) == 0 {
return "", errors.New("Message not found")
}
return messages[0].ID, nil
}
// MarkRead will mark a message as read
func MarkRead(id string) error {
if !IsUnread(id) {
return nil
}
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
return err
}
// MarkAllRead will mark all messages as read
func MarkAllRead() error {
var (
start = time.Now()
total = CountUnread()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
return nil
}
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
return err
}
// 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
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
args = make([]interface{}, len(toDelete))
for i, id := range toDelete {
args[i] = id
}
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
}
}
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))
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()
// broadcast individual message deletions
for _, id := range toDelete {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
return nil
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages() error {
var (
start = time.Now()
total int
)
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
// begin a transaction to ensure both the message
// summaries and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
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 {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
vacuumDb()
dbLastAction = time.Now()
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Warnf("[db] %s", err.Error())
}
logMessagesDeleted(total)
BroadcastMailboxStats()
websockets.Broadcast("truncate", nil)
return err
}

View File

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

View File

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

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

@@ -0,0 +1,146 @@
package storage
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/mail"
"os"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
)
// ReindexAll will regenerate the search text and snippet for a message
// and update the database.
func ReindexAll() {
ids := []string{}
var i string
chunkSize := 1000
finished := 0
err := sqlf.Select("ID").To(&i).
From(tenant("mailbox")).
OrderBy("Created DESC").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
ids = append(ids, i)
})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
os.Exit(1)
}
total := len(ids)
chunks := chunkBy(ids, chunkSize)
logger.Log().Infof("reindexing %d messages", total)
type updateStruct struct {
// ID in database
ID string
// SearchText for searching
SearchText string
// Snippet for UI
Snippet string
// Metadata info
Metadata string
}
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
for _, ids := range chunks {
updates := []updateStruct{}
for _, id := range ids {
raw, err := GetMessageRaw(id)
if err != nil {
logger.Log().Error(err)
continue
}
r := bytes.NewReader(raw)
env, err := parser.ReadEnvelope(r)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := DBMailSummary{
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
}
MetadataJSON, err := json.Marshal(obj)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
searchText := createSearchText(env)
snippet := tools.CreateSnippet(env.Text, env.HTML)
u := updateStruct{}
u.ID = id
u.SearchText = searchText
u.Snippet = snippet
u.Metadata = string(MetadataJSON)
updates = append(updates, u)
}
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
// roll back if it fails
defer tx.Rollback()
// insert mail summary data
for _, u := range updates {
_, 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
}
}
if err := tx.Commit(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
finished += len(updates)
logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
}
}
func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
for chunkSize < len(items) {
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
}
return append(chunks, items)
}

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

@@ -0,0 +1,152 @@
package storage
import (
"bytes"
"embed"
"log"
"path"
"sort"
"strings"
"text/template"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
)
//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
}
}
}
}
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")
}
}

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,6 @@
-- DROP LEGACY MIGRATION TABLE
DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }};
-- DROP LEGACY TAGS COLUMN
DROP INDEX IF EXISTS {{ tenant "idx_tags" }};
ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags;

View File

@@ -0,0 +1,22 @@
-- Rebuild message_tags to remove FOREIGN KEY REFERENCES
PRAGMA foreign_keys=OFF;
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }};
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }};
ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old;
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
TagID INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID);
INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old;
DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
-- CREATE Compressed COLUMN IN mailbox_data
ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';
-- SET Compressed = 1 for all existing data
UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1;

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>" }}`

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

@@ -0,0 +1,478 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
q := searchQueryBuilder(search, timezone)
if beforeTS > 0 {
q = q.Where(`Created < ?`, beforeTS)
}
var err error
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 float64
var attachments int
var snippet string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// DeleteSearch will delete all messages for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func DeleteSearch(search, timezone string) error {
q := searchQueryBuilder(search, timezone)
ids := []string{}
deleteSize := float64(0)
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 float64
var attachments int
var read int
var snippet string
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
deleteSize = deleteSize + size
}); err != nil {
return err
}
if len(ids) > 0 {
total := len(ids)
// split ids into chunks of 1000 ids
var chunks [][]string
if total > 1000 {
chunkSize := 1000
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
for chunkSize < len(ids) {
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
}
if len(ids) > 0 {
// add remaining ids <= 1000
chunks = append(chunks, ids)
}
} else {
chunks = append(chunks, ids)
}
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
for _, ids := range chunks {
delIDs := make([]interface{}, len(ids))
for i, id := range ids {
delIDs[i] = id
}
sqlDelete1 := `DELETE FROM ` + tenant("mailbox") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil {
return err
}
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 ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete3, delIDs...)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := pruneUnusedTags(); err != nil {
return err
}
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
dbLastAction = time.Now()
// broadcast changes
if len(ids) > 200 {
websockets.Broadcast("prune", nil)
} else {
for _, id := range ids {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
}
addDeletedSize(int64(deleteSize))
logMessagesDeleted(total)
BroadcastMailboxStats()
}
return nil
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
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,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,
IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON
`).
OrderBy("m.Created DESC")
for _, w := range args {
if cleanString(w) == "" {
continue
}
// lowercase search to try match search prefixes
lw := strings.ToLower(w)
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
lw = lw[1:]
}
// ignore blank searches
if len(w) == 0 {
continue
}
if strings.HasPrefix(lw, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "cc:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "bcc:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "reply-to:") {
w = cleanString(w[9:])
if w != "" {
if exclude {
q.Where("ReplyToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
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 != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "message-id:") {
w = cleanString(w[11:])
if w != "" {
if exclude {
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "tag:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
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 `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
}
}
} else if lw == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if lw == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if lw == "is:tagged" {
if exclude {
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 ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
}
} else if lw == "has:inline" || lw == "has:inlines" {
if exclude {
q.Where("Inline = 0")
} else {
q.Where("Inline > 0")
}
} else if lw == "has:attachment" || lw == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} 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 if strings.HasPrefix(lw, "larger:") && sizeToBytes(cleanString(w[7:])) > 0 {
w = cleanString(w[7:])
size := sizeToBytes(w)
if exclude {
q.Where("Size < ?", size)
} else {
q.Where("Size > ?", size)
}
} else if strings.HasPrefix(lw, "smaller:") && sizeToBytes(cleanString(w[8:])) > 0 {
w = cleanString(w[8:])
size := sizeToBytes(w)
if exclude {
q.Where("Size > ?", size)
} else {
q.Where("Size < ?", size)
}
} else {
// search text
if exclude {
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
} else {
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
}
}
}
return q
}
// Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m.
//
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
// M, m, Mb, MB and mb are treated as Megabytes.
func sizeToBytes(v string) int64 {
v = strings.ToLower(v)
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
m := re.FindAllStringSubmatch(v, -1)
if len(m) == 0 {
return 0
}
val := fmt.Sprintf("%s%s", m[0][1], m[0][2])
unit := m[0][3]
i, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil {
return 0
}
if unit == "" {
return int64(i)
}
if unit == "k" || unit == "kb" {
return int64(i * 1024)
}
if unit == "m" || unit == "mb" {
return int64(i * 1024 * 1024)
}
return 0
}

View File

@@ -0,0 +1,225 @@
package storage
import (
"bytes"
"fmt"
"math/rand"
"testing"
"github.com/axllent/mailpit/config"
"github.com/jhillyerd/enmime"
)
func TestSearch(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing search")
} else {
t.Logf("Testing search (tenant %s)", tenantID)
}
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%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)).
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-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()
if _, err := Store(&bufBytes); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 51; i++ {
// search a random something that will return a single result
uniqueSearches := []string{
fmt.Sprintf("from-%d@example.com", i),
fmt.Sprintf("from:from-%d@example.com", i),
fmt.Sprintf("to-%d@example.com", i),
fmt.Sprintf("to:to-%d@example.com", i),
fmt.Sprintf("to2-%d@example.com", i),
fmt.Sprintf("to:to2-%d@example.com", i),
fmt.Sprintf("cc-%d@example.com", i),
fmt.Sprintf("cc:cc-%d@example.com", i),
fmt.Sprintf("cc2-%d@example.com", i),
fmt.Sprintf("cc:cc2-%d@example.com", i),
fmt.Sprintf("reply-to-%d@example.com", i),
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
fmt.Sprintf("\"Subject line %d end\"", i),
fmt.Sprintf("subject:\"Subject line %d end\"", i),
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
}
searchIdx := rand.Intn(len(uniqueSearches))
search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "search result expected")
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
Close()
}
}
func TestSearchDelete100(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing search delete of 100 messages")
} else {
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
}
for i := 0; i < 100; i++ {
if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(&testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
Close()
}
}
func TestSearchDelete1100(t *testing.T) {
setup("")
defer Close()
t.Log("Testing search delete of 1100 messages")
for i := 0; i < 1100; i++ {
if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 1100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}
func TestEscPercentChar(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["this is% a test"] = "this is%% a test"
tests["this is%% a test"] = "this is%%%% a test"
tests["this is%%% a test"] = "this is%%%%%% a test"
tests["%this is% a test"] = "%%this is%% a test"
tests["Ä"] = "Ä"
tests["Ä%"] = "Ä%%"
for search, expected := range tests {
res := escPercentChar(search)
assertEqual(t, res, expected, "no match")
}
}
func TestSizeToBytes(t *testing.T) {
tests := map[string]int64{}
tests["1m"] = 1048576
tests["1mb"] = 1048576
tests["1 M"] = 1048576
tests["1 MB"] = 1048576
tests["1k"] = 1024
tests["1kb"] = 1024
tests["1 K"] = 1024
tests["1 kB"] = 1024
tests["1.5M"] = 1572864
tests["1234567890"] = 1234567890
tests["invalid"] = 0
tests["1.2.3"] = 0
tests["1.2.3M"] = 0
for search, expected := range tests {
res := sizeToBytes(search)
assertEqual(t, res, expected, "size does not match")
}
}

View File

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

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

@@ -0,0 +1,126 @@
package storage
import (
"net/mail"
"time"
)
// Message data excluding physical attachments
//
// swagger:model Message
type Message struct {
// Database ID
ID string
// Message ID
MessageID string
// From address
From *mail.Address
// To addresses
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// ReplyTo addresses
ReplyTo []*mail.Address
// Return-Path
ReturnPath string
// Message subject
Subject string
// List-Unsubscribe header information
// swagger:ignore
ListUnsubscribe ListUnsubscribe
// Message date if set, else date received
Date time.Time
// Message tags
Tags []string
// Message body text
Text string
// Message body HTML
HTML string
// Message size in bytes
Size float64
// Inline message attachments
Inline []Attachment
// Message attachments
Attachments []Attachment
}
// Attachment struct for inline and attachments
//
// swagger:model Attachment
type Attachment struct {
// Attachment part ID
PartID string
// File name
FileName string
// Content type
ContentType string
// Content ID
ContentID string
// Size in bytes
Size float64
}
// MessageSummary struct for frontend messages
//
// swagger:model MessageSummary
type MessageSummary struct {
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
From *mail.Address
// To address
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// Reply-To address
ReplyTo []*mail.Address
// Email subject
Subject string
// Created time
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size float64
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
Snippet string
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total float64
Unread float64
Tags []string
}
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
ReplyTo []*mail.Address
}
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
// including validation of the link structure
type ListUnsubscribe struct {
// List-Unsubscribe header value
Header string
// Detected links, maximum one email and one HTTP(S)
Links []string
// Validation errors if any
Errors string
// List-Unsubscribe-Post value if set
HeaderPost string
}

View File

@@ -0,0 +1,87 @@
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 is the user-defined match
Match string
// SQL represents the SQL equivalent of Match
SQL *sqlf.Stmt
// Tags to add on match
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
}

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

@@ -0,0 +1,405 @@
package storage
import (
"bytes"
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
var (
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
addTagMutex sync.RWMutex
)
// 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) && !tools.InArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
for _, t := range applyTags {
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
continue
}
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 !tools.InArray(t, applyTags) {
if err := deleteMessageTag(id, t); err != nil {
return []string{}, err
}
}
}
}
d := struct {
ID string
Tags []string
}{ID: id, Tags: applyTags}
websockets.Broadcast("update", d)
return tagNames, nil
}
// AddMessageTag adds a tag to a message
func addMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
var tagID int
var foundName sql.NullString
q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name)
// 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 exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return "", err
}
if exists > 0 {
// already exists
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return foundName.String, err
}
// new tag, add to the database
if _, err := sqlf.InsertInto(tenant("tags")).
Set("Name", name).
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
return name, err
}
addTagMutex.Unlock()
// add tag to the message
return addMessageTag(id, name)
}
// DeleteMessageTag deletes a tag from a message
func deleteMessageTag(id, name string) error {
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 `+tenant("tags")+` ON TagID=`+tenant("tags.ID")+` WHERE Name = ?)`, name).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
return pruneUnusedTags()
}
// DeleteAllMessageTags deleted all tags from a message
func DeleteAllMessageTags(id string) error {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
return pruneUnusedTags()
}
// GetAllTags returns all used tags
func GetAllTags() []string {
var tags = []string{}
var name string
if err := sqlf.
Select(`DISTINCT Name`).
From(tenant("tags")).To(&name).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
return tags
}
// GetAllTagsCount returns all used tags with their total messages
func GetAllTagsCount() map[string]int64 {
var tags = make(map[string]int64)
var name string
var total int64
if err := sqlf.
Select(`Name`).To(&name).
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(context.TODO(), db, func(row *sql.Rows) {
tags[name] = total
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
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(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(context.TODO(), db, func(row *sql.Rows) {
var n string
var id int
var c int
if err := row.Scan(&id, &n, &c); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return
}
if c == 0 {
logger.Log().Debugf("[tags] deleting unused tag \"%s\"", n)
toDel = append(toDel, id)
}
}); err != nil {
return err
}
if len(toDel) > 0 {
for _, id := range toDel {
if _, err := sqlf.DeleteFrom(tenant("tags")).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
}
}
return nil
}
// 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 {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
str := bytes.ToLower(*message)
for _, t := range tagFilters {
if bytes.Contains(str, []byte(t.Match)) {
tags = append(tags, t.Tags...)
}
}
return tags
}
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
func (d DBMailSummary) tagsFromPlusAddresses() []string {
tags := []string{}
for _, c := range d.To {
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.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Bcc {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
return tools.SetTagCasing(tags)
}
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
var name string
if err := sqlf.
Select(`Name`).To(&name).
From(tenant("Tags")).
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
Where(tenant("message_tags.ID")+` = ?`, id).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return tags
}
return tags
}
// SortedUniqueTags will return a unique slice of normalised tags
func sortedUniqueTags(s []string) []string {
tags := []string{}
added := make(map[string]bool)
if len(s) == 0 {
return tags
}
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) {
added[lc] = true
tags = append(tags, w)
} else {
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
}
}
sort.Strings(tags)
return tags
}

View File

@@ -0,0 +1,143 @@
package storage
import (
"fmt"
"strings"
"testing"
"github.com/axllent/mailpit/config"
)
func TestTags(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing tags")
} else {
t.Logf("Testing tags (tenant %s)", tenantID)
}
ids := []string{}
for i := 0; i < 10; i++ {
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
ids = append(ids, id)
}
for i := 0; i < 10; i++ {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 0; i < 10; i++ {
message, err := GetMessage(ids[i])
if err != nil {
t.Log("error ", err)
t.Fail()
}
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
t.Fatal("Message tags do not match")
}
}
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
newTags := []string{}
for i := 0; i < 20; i++ {
// 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 {
t.Log("error ", err)
t.Fail()
}
returnedTags := getMessageTags(id)
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
// remove first tag
if err := deleteMessageTag(id, newTags[0]); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
// remove all tags
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
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 {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// apply tag with invalid characters
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// Check deleted message tags also prune the tags database
allTags := GetAllTags()
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err = Store(&testTagEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
Close()
}
}

View File

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

49
internal/storage/testdata/tags.eml vendored Normal file
View File

@@ -0,0 +1,49 @@
Date: Wed, 27 Jul 2022 15:44:41 +1200
From: Sender Smith <sender+FromFag@example.com>
To: Recipient Ross <recipient+ToTag@example.com>
Cc: Recipient Ross <cc+CcTag@example.com>
Bcc: <bcc+BccTag@example.com>
Subject: Plain text message
X-Tags: X-tag1, X-tag2
Message-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost>
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
Content-Disposition: inline
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia,
fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non
hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat,
mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at
posuere libero. Fusce a gravida nibh. Nulla ac odio ex.
Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus.
Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis
sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue
ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis
eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet
orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet.
Pellentesque enim nibh, varius at ante id, consequat posuere ante.
Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra
vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec
et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque
condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet
tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus
massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor
et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur
nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum
sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel
ipsum. Cras condimentum posuere vulputate.
Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est
augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget
justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales.
Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero
venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in
nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt.
Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum
vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse
mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu
arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis
lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget
lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.

View File

@@ -0,0 +1,69 @@
package storage
import (
"fmt"
"os"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
var (
testTextEmail []byte
testTagEmail []byte
testMimeEmail []byte
testRuns = 100
)
func setup(tenantID string) {
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
config.TenantID = config.DBTenantID(tenantID)
if err := InitDB(); err != nil {
panic(err)
}
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)
}
testTagEmail, err = os.ReadFile("testdata/tags.eml")
if err != nil {
panic(err)
}
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
if err != nil {
panic(err)
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if float64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
}
if float64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
}
}

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

@@ -0,0 +1,108 @@
package storage
import (
"net/mail"
"os"
"regexp"
"strings"
"sync"
"github.com/axllent/mailpit/internal/html2text"
"github.com/axllent/mailpit/internal/logger"
"github.com/jhillyerd/enmime"
)
var (
// for stats to prevent import cycle
mu sync.RWMutex
// StatsDeleted for counting the number of messages deleted
StatsDeleted float64
)
// AddTempFile adds a file to the slice of files to delete on exit
func AddTempFile(s string) {
temporaryFiles = append(temporaryFiles, s)
}
// DeleteTempFiles will delete files added via AddTempFiles
func deleteTempFiles() {
for _, f := range temporaryFiles {
if err := os.Remove(f); err == nil {
logger.Log().Debugf("removed temporary file: %s", f)
}
}
}
// Return a header field as a []*mail.Address, or "null" is not found/empty
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
data, err := env.AddressList(key)
if err != nil || data == nil {
return []*mail.Address{}
}
return data
}
// Generate the search text based on some header fields (to, from, subject etc)
// and either the stripped HTML body (if exists) or text body
func createSearchText(env *enmime.Envelope) string {
var b strings.Builder
b.WriteString(env.GetHeader("From") + " ")
b.WriteString(env.GetHeader("Subject") + " ")
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
b.WriteString(env.GetHeader("Reply-To") + " ")
b.WriteString(env.GetHeader("Return-Path") + " ")
h := html2text.Strip(env.HTML, true)
if h != "" {
b.WriteString(h + " ")
} else {
b.WriteString(env.Text + " ")
}
// add attachment filenames
for _, a := range env.Attachments {
b.WriteString(a.FileName + " ")
}
d := cleanString(b.String())
return d
}
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
str = strings.ReplaceAll(str, string('\uFEFF'), " ")
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
}
// LogMessagesDeleted logs the number of messages deleted
func logMessagesDeleted(n int) {
mu.Lock()
StatsDeleted = StatsDeleted + float64(n)
mu.Unlock()
}
// IsFile returns whether a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// Convert `%` to `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}

View File

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

23
internal/tools/fs.go Normal file
View File

@@ -0,0 +1,23 @@
package tools
import (
"os"
"path/filepath"
)
// IsFile returns whether a file exists and is readable
func IsFile(path string) bool {
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 err != nil || os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}

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