Compare commits

...

289 Commits

Author SHA1 Message Date
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
118 changed files with 13034 additions and 5253 deletions

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,11 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9.0.0
- uses: actions/stale@v9.1.0
with:
days-before-issue-stale: 7
days-before-issue-close: 3
exempt-issue-labels: "enhancement,bug,javascript,docker"
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."

View File

@@ -8,7 +8,7 @@ jobs:
test:
strategy:
matrix:
go-version: [1.21.x]
go-version: ['1.23']
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -26,7 +26,7 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./internal/tools ./internal/html2text -v
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/linkcheck -v
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets
@@ -44,6 +44,6 @@ jobs:
# validate the swagger file
- name: Validate OpenAPI definition
if: startsWith(matrix.os, 'ubuntu') == true
uses: char0n/swagger-editor-validate@v1
uses: swaggerexpert/swagger-editor-validate@v1
with:
definition-file: server/ui/api/v1/swagger.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM golang:alpine as builder
FROM golang:alpine AS builder
ARG VERSION=dev
@@ -6,7 +6,7 @@ COPY . /app
WORKDIR /app
RUN apk add --no-cache git npm && \
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
@@ -21,7 +21,7 @@ LABEL org.opencontainers.image.title="Mailpit" \
COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
RUN apk upgrade --no-cache && apk add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp

View File

@@ -46,8 +46,10 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
- 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

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.

View File

@@ -2,9 +2,9 @@ package cmd
import (
"bytes"
"fmt"
"io"
"net/mail"
"net/smtp"
"os"
"path/filepath"
"strings"
@@ -13,8 +13,6 @@ import (
"github.com/axllent/mailpit/internal/logger"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var (
@@ -36,7 +34,6 @@ The --recent flag will only consider files with a modification date within the l
var count int
var total int
var per100start = time.Now()
p := message.NewPrinter(language.English)
for _, a := range args {
err := filepath.Walk(a,
@@ -108,7 +105,7 @@ The --recent flag will only consider files with a modification date within the l
}
}
err = smtp.SendMail(sendmail.SMTPAddr, nil, returnPath, recipients, body)
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
if err != nil {
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
return nil
@@ -117,8 +114,7 @@ The --recent flag will only consider files with a modification date within the l
count++
total++
if count%100 == 0 {
formatted := p.Sprintf("%d", total)
logger.Log().Infof("[%s] 100 messages in %s", formatted, time.Since(per100start))
logger.Log().Infof("[%s] 100 messages in %s", format(total), time.Since(per100start))
per100start = time.Now()
}
@@ -149,3 +145,29 @@ func isFile(path string) bool {
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] = ','
}
}
}

View File

@@ -9,10 +9,11 @@ import (
"github.com/axllent/mailpit/config"
"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/server/smtpd"
"github.com/axllent/mailpit/server/webhook"
"github.com/spf13/cobra"
)
@@ -82,8 +83,10 @@ func init() {
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().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")
@@ -120,6 +123,13 @@ func init() {
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)")
@@ -130,6 +140,7 @@ func init() {
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")
@@ -172,9 +183,14 @@ func initConfigFromEnv() {
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
}
@@ -200,7 +216,7 @@ func initConfigFromEnv() {
}
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
logger.Log().Errorf(err.Error())
logger.Log().Error(err.Error())
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
@@ -223,7 +239,7 @@ func initConfigFromEnv() {
}
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
logger.Log().Errorf(err.Error())
logger.Log().Error(err.Error())
}
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
@@ -270,7 +286,30 @@ func initConfigFromEnv() {
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 {
@@ -278,7 +317,7 @@ func initConfigFromEnv() {
}
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
logger.Log().Errorf(err.Error())
logger.Log().Error(err.Error())
}
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
@@ -287,6 +326,7 @@ func initConfigFromEnv() {
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 {
@@ -295,6 +335,9 @@ func initConfigFromEnv() {
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

View File

@@ -12,13 +12,13 @@ var sendmailCmd = &cobra.Command{
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
}
func init() {
rootCmd.AddCommand(sendmailCmd)
var ignored string
// print out manual help screen
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
@@ -27,10 +27,13 @@ func init() {
// multi-letter single-dash variables (-bs)
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().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("long-i", "i", false, "Ignored")
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
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,7 +5,6 @@ import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
@@ -14,8 +13,9 @@ import (
"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"
"gopkg.in/yaml.v3"
"github.com/axllent/mailpit/internal/tools"
)
var (
@@ -30,11 +30,22 @@ var (
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
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
// 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
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
MaxAgeInHours int
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
@@ -97,22 +108,16 @@ var (
// TagFilters are used to apply tags to new mail
TagFilters []autoTag
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
// 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 relay SMTP server
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
SMTPRelayConfig SMTPRelayConfigStruct
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
SMTPStrictRFCHeaders bool
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
SMTPAllowedRecipients string
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
SMTPAllowedRecipientsRegexp *regexp.Regexp
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
@@ -126,6 +131,22 @@ var (
// 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"
@@ -159,8 +180,14 @@ var (
// 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
@@ -180,12 +207,31 @@ type SMTPRelayConfigStruct struct {
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 {
cssFontRestriction := "*"
@@ -193,6 +239,9 @@ func VerifyConfig() error {
cssFontRestriction = "'self'"
}
// 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,
)
@@ -201,21 +250,22 @@ func VerifyConfig() error {
Database = filepath.Join(Database, "mailpit.db")
}
TenantID = strings.TrimSpace(TenantID)
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(`[^a-zA-Z0-9\_]`)
TenantID = re.ReplaceAllString(TenantID, "_")
if !strings.HasSuffix(TenantID, "_") {
TenantID = TenantID + "_"
}
}
re := regexp.MustCompile(`.*:\d+$`)
if !re.MatchString(SMTPListen) {
if _, _, isSocket := tools.UnixSocket(SMTPListen); !isSocket && !re.MatchString(SMTPListen) {
return errors.New("[smtp] bind should be in the format of <ip>:<port>")
}
if !re.MatchString(HTTPListen) {
if _, _, isSocket := tools.UnixSocket(HTTPListen); !isSocket && !re.MatchString(HTTPListen) {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
@@ -317,6 +367,14 @@ func VerifyConfig() error {
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)
@@ -383,7 +441,7 @@ func VerifyConfig() error {
}
}
// load tag filters
// load tag filters & options
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
@@ -391,6 +449,9 @@ func VerifyConfig() error {
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}
if err := parseTagsDisable(TagsDisable); err != nil {
return err
}
if SMTPAllowedRecipients != "" {
restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)
@@ -419,12 +480,12 @@ func VerifyConfig() error {
if SMTPRelayAll {
logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled")
} else {
restrictRegexp, err := regexp.Compile(SMTPRelayMatching)
re, err := regexp.Compile(SMTPRelayMatching)
if err != nil {
return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error())
}
SMTPRelayMatchingRegexp = restrictRegexp
SMTPRelayMatchingRegexp = re
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
@@ -435,117 +496,20 @@ func VerifyConfig() error {
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
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("[smtp] relay configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
// separate forwarding config validation to account for environment variables
if err := validateForwardConfig(); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("[smtp] relay host not set")
}
// DEPRECATED 2024/03/12
if SMTPRelayConfig.RecipientAllowlist != "" {
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
if SMTPRelayConfig.AllowedRecipients == "" {
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
}
if DemoMode {
MaxMessages = 1000
// this deserves a warning
logger.Log().Info("demo mode enabled")
}
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("[smtp] 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("[smtp] 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("[smtp] relay host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
}
ReleaseEnabled = true
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
if SMTPRelayConfig.AllowedRecipients != "" {
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = allowlistRegexp
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
}
return nil
}
// 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")
}

View File

@@ -11,6 +11,14 @@ import (
"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"`
}
@@ -79,3 +87,25 @@ func loadTagsFromArgs(c string) error {
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

@@ -20,7 +20,13 @@ const ctx = await esbuild.context(
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
},
outdir: "server/ui/dist/",
plugins: [pluginVue(), sassPlugin()],
plugins: [
pluginVue(),
sassPlugin({
silenceDeprecations: ['import'],
quietDeps: true,
})
],
loader: {
".svg": "file",
".woff": "file",

58
go.mod
View File

@@ -1,50 +1,48 @@
module github.com/axllent/mailpit
go 1.21.0
go 1.23
toolchain go1.22.1
toolchain go1.23.2
require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/PuerkitoBio/goquery v1.10.1
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/jhillyerd/enmime v1.2.0
github.com/klauspost/compress v1.17.8
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.3.0
github.com/klauspost/compress v1.17.11
github.com/kovidgoyal/imaging v1.6.3
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0
github.com/mhale/smtpd v0.8.3
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
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.8.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.2
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.25.0
golang.org/x/text v0.15.0
golang.org/x/time v0.5.0
github.com/spf13/cobra v1.8.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.34.0
golang.org/x/text v0.22.0
golang.org/x/time v0.10.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.9
modernc.org/sqlite v1.34.5
)
require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // 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
@@ -54,14 +52,12 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/image v0.16.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.50.5 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
modernc.org/libc v1.61.12 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)

179
go.sum
View File

@@ -1,51 +1,50 @@
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.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
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/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/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/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/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
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/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM=
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/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.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -57,18 +56,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-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/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
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=
@@ -79,8 +78,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -89,101 +86,137 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/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/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
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.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
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-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.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
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/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 h1:qNgPs5exUA+G0C96DrPwNrvLSj7GT/9D+3WMWUcUg34=
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
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 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.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.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
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.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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -193,29 +226,27 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag=
modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.3 h1:t2CQci84jnxKw3GGnHvjGKjiNZeZqyQx/023spkk4hU=
modernc.org/ccgo/v4 v4.17.3/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg=
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.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.50.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk=
modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.9 h1:9RhNMklxJs+1596GNuAX+O/6040bvOwacTxuFcRuQow=
modernc.org/sqlite v1.29.9/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
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.12 h1:Fsnh0A7XLXylYNwIOJmKux9PhnfrIvMaMnjuyJ1t/f4=
modernc.org/libc v1.61.12/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.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
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=

View File

@@ -78,5 +78,6 @@ func clean(text string) string {
}, text)
text = re.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2024-04-19 09:12:53 +0000",
"last_update_date":"2024-11-29 15:25:23 +0000",
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
"data":[
{
@@ -627,6 +627,22 @@
"notes_by_num":{"1":"The `caption-side` property in CSS is supported but the `<caption>` HTML element is not."}
},
{
"slug":"css-clear",
"title":"clear",
"description":"Sets whether an element must be moved below (cleared) floating elements that precede it.",
"url":"https://www.caniemail.com/features/css-clear/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-09-06",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
},
{
"slug":"css-clip-path",
"title":"clip-path",
@@ -691,6 +707,22 @@
"notes_by_num":null
},
{
"slug":"css-comments",
"title":"CSS comments",
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
"url":"https://www.caniemail.com/features/css-comments/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-04-25",
"test_url":"https://www.caniemail.com/tests/css-comments.html",
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.","2":"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.","3":"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.","4":"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.","5":"Partial. Comment inside `style` attribute works.","6":"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed."}
},
{
"slug":"css-conic-gradient",
"title":"conic-gradient()",
@@ -787,6 +819,22 @@
"notes_by_num":{"1":"Partial. `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` are not supported with non Google accounts.","2":"Partial. `inline-flex`, `inline-grid`, `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` values are not supported.","3":"Buggy. Only the first value is kept with the two-value syntax.","4":"Buggy. `display:none` does not inherit to inner tables.","5":"Partial. Only supports `display:none` (but not on `<img>`).","6":"Partial. `flow-root`, `inline-flex`, `inline-grid`, `inline flow`, `contents`, `revert` are not supported.","7":"Partial. Two-value syntax are combined into a single one with a dash."}
},
{
"slug":"css-empty-cells",
"title":"empty-cells",
"description":"Sets whether borders and backgrounds appear around `<table>` cells that have no visible content.",
"url":"https://www.caniemail.com/features/css-empty-cells/",
"category":"css",
"tags":[],
"keywords":"blank",
"last_test_date":"2024-08-23",
"test_url":"https://www.caniemail.com/tests/css-empty-cells.html",
"test_results_url":"https://testi.at/proj/kgl7t57xs8jxueze0v8",
"stats":{"apple-mail":{"macos":{"2024-08":"y"},"ios":{"2024-08":"y"}},"gmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"},"mobile-webmail":{"2024-08":"u"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"y"},"outlook-com":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"yahoo":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"n"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-filter",
"title":"filter",
@@ -843,12 +891,12 @@
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2019-02-28",
"last_test_date":"2024-05-08",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n","2023-01":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
"stats":{"apple-mail":{"macos":{"11.7":"a #2","12.4":"y"},"ios":{"14":"a #2","15":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"n","2023-01":"y","2024-05":"a #2"}},"aol":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect."}
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect.","2":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
},
{
@@ -925,7 +973,7 @@
"keywords":null,
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-text.html",
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
"test_results_url":"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1","2020-12":"y"},"android":{"2019-02":"a #1","2020-12":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2019-02":"y"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
@@ -947,6 +995,22 @@
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
},
{
"slug":"css-function-light-dark",
"title":"light-dark()",
"description":"Enables setting two colors (one for light and the other for dark mode) for a property.",
"url":"https://www.caniemail.com/features/css-function-light-dark/",
"category":"css",
"tags":[],
"keywords":"dark, light",
"last_test_date":"2024-08-14",
"test_url":"https://www.caniemail.com/tests/css-function-light-dark.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/Lai13xyIE95H6jo1BBs6ay0f3RvJdPL344S3j3M7FbeU4/list",
"stats":{"apple-mail":{"macos":{"16.0":"y #1"},"ios":{"17.5.1":"y"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-08":"n"},"macos":{"16.88":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"samsung-email":{"android":{"6.0":"u"}},"sfr":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"a #1 #2"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"115.10.1":"n","128.1.0":"y"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"y #1"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"a #2"}},"mail-ru":{"desktop-webmail":{"2024-08":"a #2"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #2"}},"free-fr":{"desktop-webmail":{"2024-08":"n"}},"t-online-de":{"desktop-webmail":{"2024-08":"a #2"}},"gmx":{"desktop-webmail":{"2024-08":"n"}}},
"notes":null,
"notes_by_num":{"1":"Only supported if youve updated your OS with Safari 17.5 or later.","2":"Buggy. The function is supported but the color stays light even in dark mode."}
},
{
"slug":"css-function-max",
"title":"max()",
@@ -1027,6 +1091,38 @@
"notes_by_num":{"1":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
},
{
"slug":"css-hyphenate-character",
"title":"hyphenate-character",
"description":"Sets the character (or string) used at the end of a line before a hyphenation break.",
"url":"https://www.caniemail.com/features/css-hyphenate-character/",
"category":"css",
"tags":[],
"keywords":"hyphens, break",
"last_test_date":"2024-06-19",
"test_url":"https://www.caniemail.com/tests/css-hyphenate-character.html",
"test_results_url":"https://testi.at/proj/vr3e1e5bikda08oxc2",
"stats":{"apple-mail":{"macos":{"20":"n","21":"n","22":"n","23":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"},"mobile-webmail":{"2024-06":"n"}},"orange":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-06":"n"},"macos":{"2024-06":"n"},"outlook-com":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"yahoo":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"aol":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"samsung-email":{"android":{"2024-06":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"hey":{"desktop-webmail":{"2024-06":"u"}},"mail-ru":{"desktop-webmail":{"2024-06":"a #1"}},"fastmail":{"desktop-webmail":{"2024-06":"u"}},"laposte":{"desktop-webmail":{"2024-06":"u"}},"gmx":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"web-de":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-06":"u"},"android":{"2024-06":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Does not support encoded character values"}
},
{
"slug":"css-hyphenate-limit-chars",
"title":"hyphenate-limit-chars",
"description":"Specifies the minimum word length to allow hyphenation of words as well as the minimum number of characters before and after the hyphen.",
"url":"https://www.caniemail.com/features/css-hyphenate-limit-chars/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-08-08",
"test_url":"https://www.caniemail.com/tests/css-hyphenate-limit-chars.html",
"test_results_url":"https://testi.at/proj/kgljcojhdyrfdv5s2",
"stats":{"apple-mail":{"macos":{"2024-08":"n"},"ios":{"2024-08":"n"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"n"}},"sfr":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"2024-08":"n"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"y"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"u"}},"gmx":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"web-de":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-08":"u"},"android":{"2024-08":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-hyphens",
"title":"hyphens",
@@ -1075,6 +1171,22 @@
"notes_by_num":null
},
{
"slug":"css-inset",
"title":"inset",
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
"url":"https://www.caniemail.com/features/css-inset/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-05-29",
"test_url":"https://www.caniemail.com/tests/css-inset.html",
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
"stats":{"apple-mail":{"macos":{"10.15":"n","11.7":"y"},"ios":{"14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2024-05":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"n"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"n"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-intrinsic-size",
"title":"fit-content, min-content, max-content",
@@ -1310,11 +1422,27 @@
"last_test_date":"2019-10-01",
"test_url":"https://www.caniemail.com/tests/css-margin.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/UmR6V6XenYY9bQiABuLGZRRrdP3fj2ZraiJjEyi4WKBho/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"yahoo":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"yahoo":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Not supported on `<span>` and `<body>` elements.","3":"Buggy. `background-color` is included inside the `margin`.","4":"Partial. `auto` value is not supported."}
},
{
"slug":"css-mask-image",
"title":"mask-image",
"description":"Sets the image that is used as mask layer for an element",
"url":"https://www.caniemail.com/features/css-mask-image/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-11-27",
"test_url":"https://www.caniemail.com/tests/css-mask-image.html",
"test_results_url":"https://testi.at/proj/x9aotv8ysvn805531p",
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"},"mobile-webmail":{"2024-11":"n"}},"orange":{"desktop-webmail":{"2024-11":"u"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2024-11":"n"},"macos":{"2024-11":"y"},"outlook-com":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"yahoo":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"aol":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"samsung-email":{"android":{"2024-11":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-11":"u"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"hey":{"desktop-webmail":{"2024-11":"u"}},"mail-ru":{"desktop-webmail":{"2024-11":"y"}},"fastmail":{"desktop-webmail":{"2024-11":"u"}},"laposte":{"desktop-webmail":{"2024-11":"u"}},"gmx":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"web-de":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-11":"u"},"android":{"2024-11":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-max-block-size",
"title":"max-block-size",
@@ -1347,6 +1475,22 @@
"notes_by_num":null
},
{
"slug":"css-max-inline-size",
"title":"max-inline-size",
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
"url":"https://www.caniemail.com/features/css-max-inline-size/",
"category":"css",
"tags":[],
"keywords":"max, inline, size",
"last_test_date":"2024-05-31",
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
"test_results_url":"https://testi.at/proj/8r8g0dn81y8jc72z09",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-max-width",
"title":"max-width",
@@ -1363,6 +1507,22 @@
"notes_by_num":{"1":"Partial. Only works on `<table>` elements.","2":"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths)."}
},
{
"slug":"css-min-block-size",
"title":"min-block-size",
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
"url":"https://www.caniemail.com/features/css-min-block-size/",
"category":"css",
"tags":[],
"keywords":"min, block, size",
"last_test_date":"2024-05-31",
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
"test_results_url":"https://testi.at/proj/73yg05zgtpk3cez6ua5",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-min-height",
"title":"min-height property",
@@ -1390,7 +1550,7 @@
"last_test_date":"2022-08-30",
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
"test_results_url":"https://testi.at/proj/6m0cx5puENPh8pLi9rpSPzJSB",
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"u"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
"notes":null,
"notes_by_num":null
},
@@ -1507,6 +1667,22 @@
"notes_by_num":null
},
{
"slug":"css-orphans",
"title":"orphans",
"description":"Sets the minimum number of lines in a block container split on an old page, region or column.",
"url":"https://www.caniemail.com/features/css-orphans/",
"category":"css",
"tags":[],
"keywords":"columns",
"last_test_date":"2024-06-13",
"test_url":"https://www.caniemail.com/tests/css-widows.html",
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `orphans` to work","2":"Buggy. `orphans` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `orphans` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
},
{
"slug":"css-outline-offset",
"title":"outline-offset",
@@ -1558,17 +1734,17 @@
{
"slug":"css-overflow",
"title":"overflow",
"description":"",
"description":"Sets the desired behavior when content does not fit in the element's padding box",
"url":"https://www.caniemail.com/features/css-overflow/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2019-02-28",
"last_test_date":"2024-10-02",
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
"stats":{"apple-mail":{"macos":{"12.4":"a #1"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"n #2","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"test_results_url":"https://testi.at/proj/p4rru3ez069p15p6ij",
"stats":{"apple-mail":{"macos":{"12.4":"a #1","2024-10":"a #4"},"ios":{"12.1":"y","2024-10":"a #4"}},"gmail":{"desktop-webmail":{"2019-02":"y","2024-10":"a #4"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"y","2024-10":"a #4"},"mobile-webmail":{"2020-02":"y","2024-10":"a #4"}},"orange":{"desktop-webmail":{"2019-08":"n #2","2021-03":"y","2024-10":"a #3"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y","2024-10":"a #4"},"outlook-com":{"2019-02":"y","2024-01":"y","2024-10":"a #3"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"y","2024-10":"a #4"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"samsung-email":{"android":{"5.0.10.2":"y","2024-10":"a #4"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y","2024-10":"a #3"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y","2024-10":"a #3"},"ios":{"2022-06":"y","2024-10":"a #3"},"android":{"2022-06":"y","2024-10":"a #3"}},"web-de":{"desktop-webmail":{"2022-06":"y","2024-10":"a #3"},"ios":{"2022-06":"y","2024-10":"a #3"},"android":{"2022-06":"y","2024-10":"a #3"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. Cannot scroll through to hidden content.","2":"Not supported. `overflow` is replaced by `java-script`."}
"notes_by_num":{"1":"Buggy. Cannot scroll through to hidden content.","2":"Not supported. `overflow` is replaced by `java-script`.","3":"Partial. Support for `overflow-block` & `overflow-inline` depends on browser support.","4":"Partial. `overflow-block` & `overflow-inline` not supported."}
},
{
@@ -2524,8 +2700,8 @@
"tags":[],
"keywords":"underline",
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-text.html",
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
"test_url":"https://www.caniemail.com/tests/css-text-decoration.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/3r2uYHjW7RohepVjh05qVkSQ9t7gJVJd6O5ABI8grFvqQ/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"a #2 #3","2010":"a #2 #3","2013":"a #2 #3","2016":"a #2 #3","2019":"a #2 #3"},"windows-mail":{"2019-02":"a #2 #3"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"a #4"},"ios":{"2022-07":"a #5"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"a #4"},"ios":{"2022-07":"a #5"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Not supported with multiple values.","3":"Partial. `overline` is not supported.","4":"Partial. Only supports the line property, not style, color or thickness.","5":"Partial. Only supports style, color or thickness when written with long hand selectors."}
@@ -2579,6 +2755,22 @@
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported."}
},
{
"slug":"css-text-justify",
"title":"text-justify",
"description":"Sets what type of justification should be applied to text when `text-align: justify;` is set on an element.",
"url":"https://www.caniemail.com/features/css-text-justify/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-04-17",
"test_url":"https://www.caniemail.com/tests/css-text-justify.html",
"test_results_url":"https://testi.at/proj/z7b61px4fel2ivk9sb2",
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `text-justify` is stripped","2":"Partial. Depends on browser support","3":"Partial. `text-justify` is stripped except when the value is `inter-character`","4":"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`","5":"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`"}
},
{
"slug":"css-text-orientation",
"title":"text-orientation",
@@ -2990,9 +3182,9 @@
"last_test_date":"2020-02-25",
"test_url":"https://www.caniemail.com/tests/css-units.html",
"test_results_url":"",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y #1","2024-01":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #1"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
"notes":"",
"notes_by_num":{}
"notes_by_num":{"1":"The HTML of the email message is embedded directly on the webmail (not in an <iframe>) and may not fill the full viewport's width. In this case, the vw values are relevant to the viewport (browser window) not the email message."}
},
{
@@ -3059,6 +3251,22 @@
"notes_by_num":{"1":"Buggy. `visibility:collapse` applied to a `<tr>` only hides content and does not \"remove\" it from layout.","2":"Partially supported. `visibility:collapse` is not supported."}
},
{
"slug":"css-white-space-collapse",
"title":"white-space-collapse",
"description":"Controls how white space inside an element is collapsed.",
"url":"https://www.caniemail.com/features/css-white-space-collapse/",
"category":"css",
"tags":[],
"keywords":"break, space, collapse, hide",
"last_test_date":"2024-09-04",
"test_url":"https://www.caniemail.com/tests/css-white-space-collapse.html",
"test_results_url":"https://testi.at/proj/e6y4s3zytp5kty7kcg",
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"n","13":"n","14":"y #1"},"ios":{"15":"y #1"}},"gmail":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"},"mobile-webmail":{"2024-09":"n"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"n"},"outlook-com":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"yahoo":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"aol":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"samsung-email":{"android":{"2024-09":"y #1"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y #1"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial. `preserve-spaces` value works only on Firefox."}
},
{
"slug":"css-white-space",
"title":"white-space",
@@ -3075,6 +3283,22 @@
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. `pre` value is not supported."}
},
{
"slug":"css-widows",
"title":"widows",
"description":"Sets the minimum number of lines in a block container split on a new page, region or column.",
"url":"https://www.caniemail.com/features/css-widows/",
"category":"css",
"tags":[],
"keywords":"columns",
"last_test_date":"2024-05-03",
"test_url":"https://www.caniemail.com/tests/css-widows.html",
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `widows` to work","2":"Buggy. `widows` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `widows` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
},
{
"slug":"css-width",
"title":"width property",
@@ -3443,6 +3667,38 @@
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. The element is present but is not interactive."}
},
{
"slug":"html-cellpadding",
"title":"cellpadding attribute",
"description":"Represents the padding around the individual cells of the table",
"url":"https://www.caniemail.com/features/html-cellpadding/",
"category":"html",
"tags":[],
"keywords":null,
"last_test_date":"2024-05-01",
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"html-cellspacing",
"title":"cellspacing attribute",
"description":"Represents the spacing around the individual `<th>` and `<td>` elements",
"url":"https://www.caniemail.com/features/html-cellspacing/",
"category":"html",
"tags":[],
"keywords":null,
"last_test_date":"2024-05-01",
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"html-code",
"title":"<code> element",
@@ -3459,6 +3715,22 @@
"notes_by_num":{"1":"Not supported. The tags are removed but the content is kept."}
},
{
"slug":"html-comments",
"title":"HTML comments",
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
"url":"https://www.caniemail.com/features/html-comments/",
"category":"html",
"tags":[],
"keywords":null,
"last_test_date":"2024-05-1",
"test_url":"https://www.caniemail.com/tests/css-comments.html",
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"html-del",
"title":"<del> element",
@@ -3931,12 +4203,12 @@
"category":"html",
"tags":["accessibility","performance"],
"keywords":"picture, responsive image",
"last_test_date":"2019-05-29",
"last_test_date":"2024-04-15",
"test_url":"https://www.caniemail.com/tests/html-picture.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/AQoLHTLaC6F6JcMrkx38M7oyiJlAlXeRnJgkK06bSJiBR/list",
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
"test_results_url":"https://testi.at/proj/vr32cxxk1exntxrjfdp",
"stats":{"apple-mail":{"macos":{"10.3":"y","10.15":"a #2","11.7":"a #2","12.7":"a #2","13.6":"a #2","14.4":"a #2"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
"notes":"",
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags."}
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.","2":"`<picture>` tag is stripped in some cases (like having too few content or no background-color)."}
},
{
@@ -4157,7 +4429,7 @@
"keywords":null,
"last_test_date":"2019-02-28",
"test_url":"https://www.caniemail.com/tests/css-text.html",
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
"test_results_url":"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2019-02":"y"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
@@ -4366,7 +4638,7 @@
"last_test_date":"2020-02-06",
"test_url":"https://www.caniemail.com/tests/images.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n"},"ios":{"2020-02":"n"},"android":{"2020-02":"n"},"mobile-webmail":{"2020-02":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"n #2","2021-03":"y","2024-04":"y"},"ios":{"2020-02":"n","2024-04":"y"},"android":{"2020-02":"n","2024-04":"y"}},"sfr":{"desktop-webmail":{"2020-02":"n #2"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n #2"}},"gmx":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2024-05":"n"},"ios":{"2020-02":"n"},"android":{"2020-02":"n"},"mobile-webmail":{"2020-02":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"n #2","2021-03":"y","2024-04":"y"},"ios":{"2020-02":"n","2024-04":"y"},"android":{"2020-02":"n","2024-04":"y"}},"sfr":{"desktop-webmail":{"2020-02":"n #2"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n #2"}},"gmx":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial: Does not render Base 64 gif format.","2":"Not supported. The `src` is turned into a `nosrc` attribute.","3":"Partial: Only supports Base 64 png format."}
},
@@ -4510,7 +4782,7 @@
"last_test_date":"2023-01-15",
"test_url":"https://www.caniemail.com/tests/images.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"y"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partially supported. Only works with non Google accounts."}
},

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

@@ -10,7 +10,7 @@ import (
"github.com/axllent/mailpit/internal/tools"
)
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
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) {

View File

@@ -9,6 +9,7 @@ import (
"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 {
@@ -19,6 +20,11 @@ func authUser(username, password string) bool {
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)
@@ -26,9 +32,10 @@ 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, 100)
list, err := storage.List(0, 0, 100)
if err != nil {
return messages, err
}
@@ -72,5 +79,6 @@ func getSafeArg(args []string, nr int) (string, error) {
if nr < len(args) {
return args[nr], nil
}
return "", errors.New("Out of range")
return "", errors.New("-ERR out of range")
}

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

View File

@@ -14,22 +14,26 @@ import (
"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"
"github.com/mhale/smtpd"
)
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 Store(origin, from, to, data)
return SaveToDatabase(origin, from, to, data)
}
// Store will attempt to save a message to the database
func Store(origin net.Addr, from string, to []string, data []byte) (string, error) {
// 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 {
// 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
@@ -38,7 +42,7 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
stats.LogSMTPRejected()
return "", err
}
@@ -83,6 +87,9 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
// 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)
@@ -189,38 +196,51 @@ func Listen() error {
}
}
smtpType := "no encryption"
if config.SMTPTLSCert != "" {
if config.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else if config.SMTPRequireTLS {
smtpType = "SSL/TLS 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 listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
// 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",
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 {
@@ -244,6 +264,39 @@ func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.A
}
}
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.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else if config.SMTPRequireTLS {
smtpType = "SSL/TLS 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()
}

View File

@@ -5,13 +5,62 @@ import (
"errors"
"fmt"
"net/smtp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
)
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Send(from string, to []string, msg []byte) error {
// 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)
@@ -39,6 +88,15 @@ func Send(from string, to []string, msg []byte) 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())
}

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
// 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
}
// RFC 5321 specifies support for minimum of 100 recipients is required.
if s.srv.MaxRecipients == 0 {
s.srv.MaxRecipients = 100
}
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
}

1582
internal/smtpd/smtpd_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@ func Ping() error {
}
var client *spamc.Client
if strings.HasPrefix("unix:", service) {
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
@@ -112,7 +112,7 @@ func Check(msg []byte) (Result, error) {
}
} else {
var client *spamc.Client
if strings.HasPrefix("unix:", service) {
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)

View File

@@ -13,6 +13,8 @@ import (
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/internal/tools"
)
// ProtoVersion is the protocol version
@@ -81,6 +83,7 @@ func (c *Client) dial() (connection, error) {
}
return net.DialUnix("unix", nil, unixAddr)
}
panic("Client.net must be either \"tcp\" or \"unix\"")
}
@@ -107,26 +110,25 @@ func (c *Client) report(email []byte) ([]string, error) {
}
bw := bufio.NewWriter(conn)
_, err = bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n")
if err != nil {
if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil {
return nil, err
}
_, err = bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n")
if err != nil {
if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil {
return nil, err
}
_, err = bw.Write(email)
if err != nil {
if _, err := bw.Write(email); err != nil {
return nil, err
}
err = bw.Flush()
if err != nil {
if err := bw.Flush(); err != nil {
return nil, err
}
// Client is supposed to close its writing side of the connection
// after sending its request.
err = conn.CloseWrite()
if err != nil {
if err := conn.CloseWrite(); err != nil {
return nil, err
}
@@ -134,6 +136,7 @@ func (c *Client) report(email []byte) ([]string, error) {
lines []string
br = bufio.NewReader(conn)
)
for {
line, err := br.ReadString('\n')
if err == io.EOF {
@@ -171,11 +174,12 @@ func (c *Client) parseOutput(output []string) Result {
continue
}
}
// summary
if spamMainRe.MatchString(row) {
res := spamMainRe.FindStringSubmatch(row)
if len(res) == 4 {
if strings.ToLower(res[1]) == "true" || strings.ToLower(res[1]) == "yes" {
if tools.InArray(res[1], []string{"true", "yes"}) {
result.Spam = true
} else {
result.Spam = false
@@ -197,8 +201,8 @@ func (c *Client) parseOutput(output []string) Result {
reachedRules = true
continue
}
// details
// row = strings.Trim(row, " \t\r\n")
if reachedRules && spamDetailsRe.MatchString(row) {
res := spamDetailsRe.FindStringSubmatch(row)
if len(res) == 5 {
@@ -207,6 +211,7 @@ func (c *Client) parseOutput(output []string) Result {
}
}
}
return result
}
@@ -222,12 +227,11 @@ func (c *Client) Ping() error {
return err
}
_, err = io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion))
if err != nil {
if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil {
return err
}
err = conn.CloseWrite()
if err != nil {
if err := conn.CloseWrite(); err != nil {
return err
}
@@ -241,5 +245,6 @@ func (c *Client) Ping() error {
return err
}
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"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"
)
@@ -48,34 +49,74 @@ func dbCron() {
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
// Set config.MaxMessages to 0 to disable.
func pruneMessages() {
if config.MaxMessages < 1 {
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
return
}
start := time.Now()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
var prunedSize int64
var size float64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
// 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
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if len(ids) == 0 {
@@ -132,6 +173,10 @@ func pruneMessages() {
logMessagesDeleted(len(ids))
if config.DemoMode {
vacuumDb()
}
websockets.Broadcast("prune", nil)
}

View File

@@ -28,25 +28,31 @@ import (
var (
db *sql.DB
dbFile string
dbIsTemp bool
sqlDriver string
dbLastAction time.Time
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbDecoder, _ = zstd.NewReader(nil)
temporaryFiles = []string{}
)
// InitDB will initialise the database
func InitDB() error {
var (
dsn string
err error
)
p := config.Database
var dsn string
if p == "" {
// when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
dbIsTemp = true
// delete the Unix socket file on exit
AddTempFile(p)
sqlDriver = "sqlite"
dsn = p
logger.Log().Debugf("[db] using temporary database: %s", p)
@@ -74,8 +80,6 @@ func InitDB() error {
}
}
var err error
db, err = sql.Open(sqlDriver, dsn)
if err != nil {
return err
@@ -154,12 +158,8 @@ func Close() {
// allow SQLite to finish closing DB & write WAL logs if local
time.Sleep(100 * time.Millisecond)
if dbIsTemp && isFile(dbFile) {
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
if err := os.Remove(dbFile); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
// delete all temporary files
deleteTempFiles()
}
// Ping the database connection and return an error if unsuccessful

View File

@@ -27,8 +27,10 @@ import (
// 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 := enmime.ReadEnvelope(bytes.NewReader(*body))
env, err := parser.ReadEnvelope(bytes.NewReader(*body))
if err != nil {
logger.Log().Warnf("[message] %s", err.Error())
return "", nil
@@ -50,7 +52,7 @@ func Store(body *[]byte) (string, error) {
ReplyTo: addressToSlice(env, "Reply-To"),
}
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
created := time.Now()
// use message date instead of created date
@@ -112,23 +114,29 @@ func Store(body *[]byte) (string, error) {
return "", err
}
// extract tags from body matches
rawTags := findTagsInRawMessage(body)
// extract plus addresses tags from enmime.Envelope
plusTags := obj.tagsFromPlusAddresses()
// extract tags from X-Tags header
xTags := tools.SetTagCasing(strings.Split(strings.TrimSpace(env.Root.Header.Get("X-Tags")), ","))
// extract tags from search matches
searchTags := tagFilterMatches(id)
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
// combine all tags into one slice
tags := append(rawTags, plusTags...)
tags = append(tags, xTags...)
// sort and extract only unique tags
tags = sortedUniqueTags(append(tags, searchTags...))
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 {
if err := SetMessageTags(id, tags); err != nil {
setTags, err = SetMessageTags(id, tags)
if err != nil {
return "", err
}
}
@@ -144,7 +152,7 @@ func Store(body *[]byte) (string, error) {
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tags
c.Tags = setTags
c.Snippet = snippet
websockets.Broadcast("new", c)
@@ -154,12 +162,14 @@ func Store(body *[]byte) (string, error) {
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, limit int) ([]MessageSummary, error) {
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
@@ -169,6 +179,10 @@ func List(start, limit int) ([]MessageSummary, error) {
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
@@ -233,7 +247,9 @@ func GetMessage(id string) (*Message, error) {
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
@@ -384,7 +400,9 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
@@ -412,6 +430,21 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
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
@@ -422,12 +455,12 @@ func LatestID(r *http.Request) (string, error) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
if err != nil {
return "", err
}
} else {
messages, err = List(0, 1)
messages, err = List(0, 0, 1)
if err != nil {
return "", err
}
@@ -456,6 +489,13 @@ func MarkRead(id string) error {
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
return err
}
@@ -528,6 +568,13 @@ func MarkUnread(id string) error {
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
return err
}
@@ -593,7 +640,9 @@ func DeleteMessages(ids []string) error {
}
}
err = tx.Commit()
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))
@@ -613,6 +662,15 @@ func DeleteMessages(ids []string) error {
BroadcastMailboxStats()
// broadcast individual message deletions
for _, id := range toDelete {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
return nil
}
@@ -663,8 +721,9 @@ func DeleteAllMessages() error {
logMessagesDeleted(total)
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
websockets.Broadcast("truncate", nil)
return err
}

View File

@@ -3,10 +3,12 @@ package storage
import (
"testing"
"time"
"github.com/axllent/mailpit/config"
)
func TestTextEmailInserts(t *testing.T) {
setup()
setup("")
defer Close()
t.Log("Testing text email storage")
@@ -38,113 +40,140 @@ func TestTextEmailInserts(t *testing.T) {
}
func TestMimeEmailInserts(t *testing.T) {
setup()
defer Close()
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
t.Log("Testing mime email storage")
setup(tenantID)
start := time.Now()
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) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
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()
}
}
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)
}
for i := 0; i < testRuns; i++ {
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()
}
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))
}
func TestRetrieveMimeEmail(t *testing.T) {
setup()
defer Close()
t.Log("Testing mime email retrieval")
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, 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")
}
func TestMessageSummary(t *testing.T) {
setup()
defer Close()
t.Log("Testing message summary")
if _, err := Store(&testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
summaries, err := List(0, 1)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "Expected 1 result")
msg := summaries[0]
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
}
func BenchmarkImportText(b *testing.B) {
setup()
setup("")
defer Close()
for i := 0; i < b.N; i++ {
@@ -156,7 +185,7 @@ func BenchmarkImportText(b *testing.B) {
}
func BenchmarkImportMime(b *testing.B) {
setup()
setup("")
defer Close()
for i := 0; i < b.N; i++ {

View File

@@ -43,12 +43,18 @@ func ReindexAll() {
logger.Log().Infof("reindexing %d messages", total)
type updateStruct struct {
ID string
// ID in database
ID string
// SearchText for searching
SearchText string
Snippet string
Metadata string
// Snippet for UI
Snippet string
// Metadata info
Metadata string
}
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
for _, ids := range chunks {
updates := []updateStruct{}
@@ -61,7 +67,7 @@ func ReindexAll() {
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
env, err := parser.ReadEnvelope(r)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
@@ -135,5 +141,6 @@ 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)
}

View File

@@ -2,20 +2,15 @@ package storage
import (
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
"log"
"path"
"sort"
"strings"
"text/template"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
"github.com/leporo/sqlf"
)
//go:embed schemas/*
@@ -63,13 +58,6 @@ func dbApplySchemas() error {
}
}
}
// delete legacy migration database after 01/10/2024
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
return err
}
}
}
schemaFiles, err := schemaScripts.ReadDir("schemas")
@@ -137,7 +125,9 @@ func dbApplySchemas() error {
buf := new(bytes.Buffer)
err = t1.Execute(buf, nil)
if err := t1.Execute(buf, nil); err != nil {
return err
}
if _, err := db.Exec(buf.String()); err != nil {
return err
@@ -159,64 +149,4 @@ func dataMigrations() {
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// TODO: Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From(tenant("mailbox")).
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

View File

@@ -0,0 +1,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

@@ -4,13 +4,16 @@ 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"
)
@@ -18,7 +21,7 @@ import (
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
@@ -28,6 +31,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
}
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) {
@@ -182,17 +190,30 @@ func DeleteSearch(search, timezone string) error {
}
}
err = tx.Commit()
if err := tx.Commit(); err != nil {
return err
}
if err := pruneUnusedTags(); err != nil {
return err
}
if err == nil {
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
}
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)
@@ -349,6 +370,12 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
} 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")
@@ -385,6 +412,22 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
}
}
}
} 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 {
@@ -397,3 +440,39 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
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

@@ -6,133 +6,154 @@ import (
"math/rand"
"testing"
"github.com/axllent/mailpit/config"
"github.com/jhillyerd/enmime"
)
func TestSearch(t *testing.T) {
setup()
defer Close()
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
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))
setup(tenantID)
env, err := msg.Build()
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")
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()
}
Close()
}
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, 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, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
}
func TestSearchDelete100(t *testing.T) {
setup()
defer Close()
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
t.Log("Testing search delete of 100 messages")
for i := 0; i < 100; i++ {
if _, err := Store(&testTextEmail); err != nil {
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()
}
if _, err := Store(&testMimeEmail); err != nil {
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()
}
_, total, err := Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}
func TestSearchDelete1100(t *testing.T) {
setup()
setup("")
defer Close()
t.Log("Testing search delete of 1100 messages")
@@ -143,7 +164,7 @@ func TestSearchDelete1100(t *testing.T) {
}
}
_, total, err := Search("from:sender@example.com", "", 0, 100)
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -156,7 +177,7 @@ func TestSearchDelete1100(t *testing.T) {
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 100)
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -180,3 +201,25 @@ func TestEscPercentChar(t *testing.T) {
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

@@ -3,8 +3,6 @@ package storage
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message data excluding physical attachments
@@ -114,21 +112,6 @@ type DBMailSummary struct {
ReplyTo []*mail.Address
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = float64(len(a.Content))
return o
}
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
// including validation of the link structure
type ListUnsubscribe struct {

View File

@@ -13,9 +13,12 @@ import (
// TagFilter struct
type TagFilter struct {
// Match is the user-defined match
Match string
SQL *sqlf.Stmt
Tags []string
// SQL represents the SQL equivalent of Match
SQL *sqlf.Stmt
// Tags to add on match
Tags []string
}
var tagFilters = []TagFilter{}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
@@ -12,6 +13,7 @@ import (
"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"
)
@@ -21,7 +23,7 @@ var (
)
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) error {
func SetMessageTags(id string, tags []string) ([]string, error) {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
@@ -30,6 +32,7 @@ func SetMessageTags(id string, tags []string) error {
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
@@ -38,9 +41,12 @@ func SetMessageTags(id string, tags []string) error {
continue
}
if err := AddMessageTag(id, t); err != nil {
return err
name, err := addMessageTag(id, t)
if err != nil {
return []string{}, err
}
tagNames = append(tagNames, name)
}
if origTagCount > 0 {
@@ -48,43 +54,52 @@ func SetMessageTags(id string, tags []string) error {
for _, t := range currentTags {
if !tools.InArray(t, applyTags) {
if err := DeleteMessageTag(id, t); err != nil {
return err
if err := deleteMessageTag(id, t); err != nil {
return []string{}, err
}
}
}
}
return nil
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) error {
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 count int
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&count).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return err
return "", err
}
if count > 0 {
if exists > 0 {
// already exists
return nil
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
@@ -93,7 +108,8 @@ func AddMessageTag(id, name string) error {
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return err
return foundName.String, err
}
// new tag, add to the database
@@ -101,20 +117,20 @@ func AddMessageTag(id, name string) error {
Set("Name", name).
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
return err
return name, err
}
addTagMutex.Unlock()
// add tag to the message
return AddMessageTag(id, name)
return addMessageTag(id, name)
}
// DeleteMessageTag deleted a tag from a message
func DeleteMessageTag(id, name string) error {
// 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 tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
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
}
@@ -166,7 +182,6 @@ func GetAllTagsCount() map[string]int64 {
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags[name] = total
// tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
@@ -174,6 +189,79 @@ func GetAllTagsCount() map[string]int64 {
return tags
}
// RenameTag renames a tag
func RenameTag(from, to string) error {
to = tools.CleanTag(to)
if to == "" || !config.ValidTagRegexp.MatchString(to) {
return fmt.Errorf("invalid tag name: %s", to)
}
if from == to {
return nil // ignore
}
var id, existsID int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, from).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", from)
}
// check if another tag by this name already exists
q = sqlf.From(tenant("tags")).
Select("ID").To(&existsID).
Where(`Name = ?`, to).
Where(`ID != ?`, id).
Limit(1)
err = q.QueryRowAndClose(context.Background(), db)
if err == nil || existsID != 0 {
return fmt.Errorf("tag already exists: %s", to)
}
q = sqlf.Update(tenant("tags")).
Set("Name", to).
Where("ID = ?", id)
_, err = q.ExecAndClose(context.Background(), db)
return err
}
// DeleteTag deleted a tag and removed all references to the tag
func DeleteTag(tag string) error {
var id int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, tag).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", tag)
}
// delete all references
q = sqlf.DeleteFrom(tenant("message_tags")).
Where(`TagID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag references: %s", err.Error())
}
// delete tag
q = sqlf.DeleteFrom(tenant("tags")).
Where(`ID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag: %s", err.Error())
}
return nil
}
// PruneUnusedTags will delete all unused tags from the database
func pruneUnusedTags() error {
q := sqlf.From(tenant("tags")).

View File

@@ -4,127 +4,140 @@ import (
"fmt"
"strings"
"testing"
"github.com/axllent/mailpit/config"
)
func TestTags(t *testing.T) {
setup()
defer Close()
t.Log("Testing tags")
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
ids := []string{}
setup(tenantID)
for i := 0; i < 10; i++ {
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()
}
ids = append(ids, id)
}
for i := 0; i < 10; i++ {
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
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")
for i := 0; i < 10; i++ {
message, err := GetMessage(ids[i])
// 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()
}
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
t.Fatal("Message tags do not match")
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()
}
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()
}
}

View File

@@ -16,10 +16,11 @@ var (
testRuns = 100
)
func setup() {
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)

View File

@@ -8,6 +8,7 @@ import (
"sync"
"github.com/axllent/mailpit/internal/html2text"
"github.com/axllent/mailpit/internal/logger"
"github.com/jhillyerd/enmime"
)
@@ -18,6 +19,20 @@ var (
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)

View File

@@ -6,6 +6,7 @@ import (
"bytes"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/internal/logger"
)
@@ -48,7 +49,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
}
if len(hdr) > 0 {
logger.Log().Debugf("[release] removed %s header", hdr)
logger.Log().Debugf("[relay] removed %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(""), 1)
}
}
@@ -90,10 +91,70 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
}
if len(hdr) > 0 {
logger.Log().Debugf("[release] replaced %s header", hdr)
logger.Log().Debugf("[relay] replaced %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
}
}
return msg, nil
}
// OverrideFromHeader scans a message for the From header and replaces it with a different email address.
func OverrideFromHeader(msg []byte, address string) ([]byte, error) {
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
if m.Header.Get("From") != "" {
reBlank := regexp.MustCompile(`^\s+`)
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta("From:"))
scanner := bufio.NewScanner(bytes.NewReader(msg))
found := false
hdr := []byte("")
for scanner.Scan() {
line := scanner.Bytes()
if !found && reHdr.Match(line) {
// add the first line starting with <header>:
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
found = true
} else if found && reBlank.Match(line) {
// add any following lines starting with a whitespace (tab or space)
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
} else if found {
// stop scanning, we have the full <header>
break
}
}
if len(hdr) > 0 {
originalFrom := strings.TrimRight(string(hdr[5:]), "\r\n")
from, err := mail.ParseAddress(originalFrom)
if err != nil {
// error parsing the from address, so just replace the whole line
msg = bytes.Replace(msg, hdr, []byte("From: "+address+"\r\n"), 1)
} else {
originalFrom = from.Address
// replace the from email, but keep the original name
from.Address = address
msg = bytes.Replace(msg, hdr, []byte("From: "+from.String()+"\r\n"), 1)
}
// insert the original From header as X-Original-From
msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...)
logger.Log().Debugf("[relay] Replaced From email address with %s", address)
}
} else {
// no From header, so add one
msg = append([]byte("From: "+address+"\r\n"), msg...)
logger.Log().Debugf("[relay] Added From email: %s", address)
}
return msg, nil
}

View File

@@ -17,3 +17,34 @@ func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
return "", fmt.Errorf("%s not found", key)
}
// SetHTMLAttributeVal sets an attribute on a node.
func SetHTMLAttributeVal(n *html.Node, key, val string) {
for i := range n.Attr {
a := &n.Attr[i]
if a.Key == key {
a.Val = val
return
}
}
n.Attr = append(n.Attr, html.Attribute{
Key: key,
Val: val,
})
}
// WalkHTML traverses the entire HTML tree and calls fn on each node.
func WalkHTML(n *html.Node, fn func(*html.Node)) {
if n == nil {
return
}
fn(n)
// Each node has a pointer to its first child and next sibling. To traverse
// all children of a node, we need to start from its first child and then
// traverse the next sibling until nil.
for c := n.FirstChild; c != nil; c = c.NextSibling {
WalkHTML(c, fn)
}
}

View File

@@ -26,7 +26,7 @@ func CreateSnippet(text, html string) string {
return data
}
return data[0:limit] + "..."
return truncate(data, limit) + "..."
}
if text != "" {
@@ -37,8 +37,33 @@ func CreateSnippet(text, html string) string {
return text
}
return text[0:limit] + "..."
return truncate(text, limit) + "..."
}
return ""
}
// Truncate a string allowing for multi-byte encoding.
// Shamelessly borrowed from Tailscale.
// See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go
func truncate(s string, n int) string {
if n >= len(s) {
return s
}
// Back up until we find the beginning of a UTF-8 encoding.
for n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte
n--
}
// If we're at the beginning of a multi-byte encoding, back up one more to
// skip it. It's possible the value was already complete, but it's simpler
// if we only have to check in one direction.
//
// Otherwise, we have a single-byte code (0x00... or 0x01...).
if n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding
n--
}
return s[:n]
}

View File

@@ -0,0 +1,49 @@
package tools
import (
"fmt"
"io/fs"
"net"
"os"
"path"
"regexp"
"strconv"
)
// UnixSocket returns a path and a FileMode if the address is in
// the format of unix:<path>:<permission>
func UnixSocket(address string) (string, fs.FileMode, bool) {
re := regexp.MustCompile(`^unix:(.*):(\d\d\d\d?)$`)
var f fs.FileMode
if !re.MatchString(address) {
return "", f, false
}
m := re.FindAllStringSubmatch(address, 1)
modeVal, err := strconv.ParseUint(m[0][2], 8, 32)
if err != nil {
return "", f, false
}
return path.Clean(m[0][1]), fs.FileMode(modeVal), true
}
// PrepareSocket returns an error if an active socket file already exists
func PrepareSocket(address string) error {
address = path.Clean(address)
if _, err := os.Stat(address); os.IsNotExist(err) {
// does not exist, OK
return nil
}
if _, err := net.Dial("unix", address); err == nil {
// socket is listening
return fmt.Errorf("socket already in use: %s", address)
}
return os.Remove(address)
}

View File

@@ -2,6 +2,7 @@ package tools
import (
"fmt"
"regexp"
"strings"
)
@@ -10,17 +11,28 @@ func Plural(total int, singular, plural string) string {
if total == 1 {
return fmt.Sprintf("%d %s", total, singular)
}
return fmt.Sprintf("%d %s", total, plural)
}
// InArray tests if a string is within an array. It is not case sensitive.
func InArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
if strings.EqualFold(v, k) {
return true
}
}
return false
}
// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces
func Normalize(s string) string {
nlRe := regexp.MustCompile(`\r?\r`)
re := regexp.MustCompile(`\s+`)
s = nlRe.ReplaceAllString(s, " ")
s = re.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}

View File

@@ -202,7 +202,7 @@ func extract(filePath string, directory string) error {
}
// set file permissions, timestamps & uid/gid
_ = os.Chmod(filename, os.FileMode(header.Mode))
_ = os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
_ = os.Chown(filename, header.Uid, header.Gid)
}

View File

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

View File

@@ -23,6 +23,7 @@ var (
// AllowPrereleases defines whether pre-releases may be included
AllowPrereleases = false
// temporary directory
tempDir string
)
@@ -329,7 +330,7 @@ func getTempDir() string {
// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
if !isDir(path) {
return os.MkdirAll(path, os.ModePerm)
return os.MkdirAll(path, os.ModePerm) // #nosec
}
return nil

View File

@@ -16,10 +16,11 @@ func main() {
}
// running directly
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) ||
!strings.Contains(filepath.Base(os.Args[0]), "sendmail") {
cmd.Execute()
} else {
// symlinked
// symlinked as "*sendmail*"
sendmail.Run()
}
}

2996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"build": "MINIFY=true node esbuild.config.mjs",
"watch": "WATCH=true node esbuild.config.mjs",
"package": "MINIFY=true node esbuild.config.mjs",
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json"
},
"dependencies": {
"axios": "^1.2.1",
@@ -15,6 +15,9 @@
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
"ical.js": "^2.0.1",
"mitt": "^3.0.1",
"modern-screenshot": "^4.4.30",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
@@ -28,7 +31,7 @@
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.20.0",
"esbuild": "^0.25.0",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0"
}

View File

@@ -18,15 +18,15 @@ import (
"fmt"
"io"
"net/mail"
"net/smtp"
"os"
"os/user"
"path"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/reiver/go-telnet"
"github.com/mneis/go-telnet"
flag "github.com/spf13/pflag"
)
@@ -35,7 +35,6 @@ var (
SMTPAddr = "localhost:1025"
// FromAddr email address
FromAddr string
// UseB - used to set from `-bs`
UseB bool
// UseS - used to set from `-bs`
@@ -83,6 +82,9 @@ func Run() {
flag.BoolP("long-i", "i", false, "Ignored")
flag.BoolP("long-o", "o", false, "Ignored")
flag.BoolP("long-t", "t", false, "Ignored")
flag.StringP("from-name", "F", "", "Ignored")
flag.StringP("bits", "B", "", "Ignored")
flag.StringP("errors", "e", "", "Ignored")
// set the default help
flag.Usage = func() {
@@ -114,14 +116,23 @@ func Run() {
os.Exit(1)
}
socketAddr, isSocket := socketAddress(SMTPAddr)
// handles `sendmail -bs`
// telnet directly to SMTP
if UseB && UseS {
var caller telnet.Caller = telnet.StandardCaller
// telnet directly to SMTP
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
if isSocket {
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
} else {
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
return
@@ -168,8 +179,7 @@ func Run() {
os.Exit(11)
}
err = smtp.SendMail(SMTPAddr, nil, from.Address, addresses, body)
if err != nil {
if err := Send(SMTPAddr, from.Address, addresses, body); err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
}
@@ -191,5 +201,22 @@ Flags:
-i Ignored
-o Ignored
-v Ignored
-F string Ignored
-B string Ignored
-e string Ignored
`, config.Version, strings.Join(args, " "), FromAddr)
}
// SocketAddress returns a path and a FileMode if the address is in
// the format of unix:<path>
func socketAddress(address string) (string, bool) {
re := regexp.MustCompile(`^unix:(.*)$`)
if !re.MatchString(address) {
return "", false
}
m := re.FindAllStringSubmatch(address, 1)
return path.Clean(m[0][1]), true
}

71
sendmail/cmd/smtp.go Normal file
View File

@@ -0,0 +1,71 @@
// Package cmd is a wrapper library to send mail
package cmd
import (
"fmt"
"net"
"net/mail"
"net/smtp"
"os"
"github.com/axllent/mailpit/internal/logger"
)
// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.
// Unix sockets must be set as unix:/path/to/socket
// It does not support authentication.
func Send(addr string, from string, to []string, msg []byte) error {
socketPath, isSocket := socketAddress(addr)
fromAddress, err := mail.ParseAddress(from)
if err != nil {
return fmt.Errorf("invalid from address: %s", from)
}
if len(to) == 0 {
return fmt.Errorf("no To addresses specified")
}
if !isSocket {
return smtp.SendMail(addr, nil, fromAddress.Address, to, msg)
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
return fmt.Errorf("error connecting to %s", addr)
}
client, err := smtp.NewClient(conn, "")
if err != nil {
return err
}
// Set the sender
if err := client.Mail(fromAddress.Address); err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
}
// Set the recipient
for _, a := range to {
if err := client.Rcpt(a); err != nil {
return err
}
}
wc, err := client.Data()
if err != nil {
return err
}
_, err = wc.Write(msg)
if err != nil {
return err
}
err = wc.Close()
if err != nil {
return err
}
return nil
}

View File

@@ -2,879 +2,16 @@
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"strconv"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// GetMessages returns a paginated list of messages as JSON
func GetMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/messages messages GetMessages
//
// # List messages
//
// Returns messages from the mailbox ordered from newest to oldest.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: start
// in: query
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: Limit results
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
res.MessagesCount = stats.Total
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// Search returns the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/search messages MessagesSummary
//
// # Search messages
//
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
// + name: start
// in: query
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: Limit results
// required: false
// type: integer
// default: 50
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
start, limit := getStartLimit(r)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = float64(results)
res.Unread = stats.Unread
res.Tags = stats.Tags
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// DeleteSearch will delete all messages matching a search
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/search messages DeleteSearch
//
// # Delete messages by search
//
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message Message
//
// # Get message summary
//
// Returns the summary of a message, marking the message as read.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: Message
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(msg); err != nil {
httpError(w, err.Error())
}
}
// DownloadAttachment (method: GET) returns the attachment data
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
//
// # Get message attachment
//
// This will return the attachment part using the appropriate Content-Type.
//
// Produces:
// - application/*
// - image/*
// - text/*
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
fourOFour(w)
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// GetHeaders (method: GET) returns the message headers as JSON
func GetHeaders(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/headers message Headers
//
// # Get message headers
//
// Returns the message headers as an array.
//
// The ID can be set to `latest` to return the latest message headers.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: MessageHeaders
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
reader := bytes.NewReader(data)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
httpError(w, err.Error())
}
}
// DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/raw message Raw
//
// # Get message source
//
// Returns the full email source as plain text.
//
// The ID can be set to `latest` to return the latest message source.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages DeleteMessages
//
// # Delete messages
//
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil || len(data.IDs) == 0 {
if err := storage.DeleteAllMessages(); err != nil {
httpError(w, err.Error())
return
}
} else {
if err := storage.DeleteMessages(data.IDs); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "application/plain")
_, _ = w.Write([]byte("ok"))
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatus
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
} else {
err := storage.MarkAllUnread()
if err != nil {
httpError(w, err.Error())
return
}
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if err := storage.SetMessageTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
httpError(w, "Mail address does not match allowlist: "+to)
return
}
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP mfrom
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Send(from, data.To, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// HTMLCheck returns a summary of the HTML client support
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
//
// # HTML check
//
// Returns the summary of the message HTML checker.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: HTMLCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
if msg.HTML == "" {
httpError(w, "message does not contain HTML")
return
}
checks, err := htmlcheck.RunTests(msg.HTML)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(checks); err != nil {
httpError(w, err.Error())
}
}
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
//
// # Link check
//
// Returns the summary of the message Link checker.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: LinkCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
f := r.URL.Query().Get("follow")
followRedirects := f == "true" || f == "1"
summary, err := linkcheck.RunTests(msg, followRedirects)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck
//
// # SpamAssassin check
//
// Returns the SpamAssassin summary (if enabled) of the message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: SpamAssassinResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
summary, err := spamassassin.Check(msg)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
@@ -909,9 +46,10 @@ func httpJSONError(w http.ResponseWriter, msg string) {
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
start = 0
limit = 50
beforeTS = 0 // timestamp
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
@@ -923,7 +61,17 @@ func getStartLimit(req *http.Request) (start int, limit int) {
limit = n
}
return start, limit
b := req.URL.Query().Get("before")
if b != "" {
t, err := dateparse.ParseLocal(b)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", b)
} else {
beforeTS = t.UnixMilli()
}
}
return start, beforeTS, limit
}
// GetOptions returns a blank response

View File

@@ -6,12 +6,48 @@ import (
"net/http"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/stats"
)
// Application information
// swagger:response AppInfoResponse
type appInfoResponse struct {
// Application information
//
// in: body
Body stats.AppInformation
}
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: AppInfoResponse
// 400: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
httpError(w, err.Error())
}
}
// Response includes global web UI settings
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
@@ -22,6 +58,10 @@ type webUIConfiguration struct {
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// Overrides the "From" address for all relayed messages
OverrideFrom string
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
@@ -30,10 +70,22 @@ type webUIConfiguration struct {
// Whether SpamAssassin is enabled
SpamAssassin bool
// Whether Chaos support is enabled at runtime
ChaosEnabled bool
// Whether messages with duplicate IDs are ignored
DuplicatesIgnored bool
}
// Web UI configuration response
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
//
// in: body
Body webUIConfiguration
}
// WebUIConfig returns configuration settings for the web UI.
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/webui application WebUIConfiguration
@@ -44,25 +96,30 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// Intended for web UI only!
//
// Produces:
// - application/json
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: WebUIConfigurationResponse
// default: ErrorResponse
// 200: WebUIConfigurationResponse
// 400: ErrorResponse
conf := webUIConfiguration{}
conf.Label = config.Label
conf.MessageRelay.Enabled = config.ReleaseEnabled
if config.ReleaseEnabled {
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
// DEPRECATED 2024/03/12
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
}
conf.SpamAssassin = config.EnableSpamAssassin != ""
conf.ChaosEnabled = chaos.Enabled
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
w.Header().Add("Content-Type", "application/json")

112
server/apiv1/chaos.go Normal file
View File

@@ -0,0 +1,112 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/smtpd/chaos"
)
// ChaosTriggers is the Chaos configuration
//
// swagger:model Triggers
type ChaosTriggers chaos.Triggers
// Response for the Chaos triggers configuration
// swagger:response ChaosResponse
type chaosResponse struct {
// The current Chaos triggers
//
// in: body
Body ChaosTriggers
}
// GetChaos returns the current Chaos triggers
func GetChaos(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/chaos testing getChaos
//
// # Get Chaos triggers
//
// Returns the current Chaos triggers configuration.
// This API route will return an error if Chaos is not enabled at runtime.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ChaosResponse
// 400: ErrorResponse
if !chaos.Enabled {
httpError(w, "Chaos is not enabled")
return
}
conf := chaos.Config
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(conf); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters setChaosParams
type setChaosParams struct {
// in: body
Body ChaosTriggers
}
// SetChaos sets the Chaos configuration.
func SetChaos(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/chaos testing setChaosParams
//
// # Set Chaos triggers
//
// Set the Chaos triggers configuration and return the updated values.
// This API route will return an error if Chaos is not enabled at runtime.
//
// If any triggers are omitted from the request, then those are reset to their
// default values with a 0% probability (ie: disabled).
// Setting a blank `{}` will reset all triggers to their default values.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ChaosResponse
// 400: ErrorResponse
if !chaos.Enabled {
httpError(w, "Chaos is not enabled")
return
}
data := chaos.Triggers{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
if err := chaos.SetFromStruct(data); err != nil {
httpError(w, err.Error())
return
}
conf := chaos.Config
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(conf); err != nil {
httpError(w, err.Error())
}
}

View File

@@ -1,31 +0,0 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/stats"
)
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: InfoResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
httpError(w, err.Error())
}
}

257
server/apiv1/message.go Normal file
View File

@@ -0,0 +1,257 @@
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
)
// swagger:parameters GetMessageParams
type getMessageParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message GetMessageParams
//
// # Get message summary
//
// Returns the summary of a message, marking the message as read.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: Message
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(msg); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters GetHeadersParams
type getHeadersParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
}
// Message headers
// swagger:model MessageHeadersResponse
type messageHeaders map[string][]string
// GetHeaders (method: GET) returns the message headers as JSON
func GetHeaders(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/headers message GetHeadersParams
//
// # Get message headers
//
// Returns the message headers as an array. Note that header keys are returned alphabetically.
//
// The ID can be set to `latest` to return the latest message headers.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: MessageHeadersResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
reader := bytes.NewReader(data)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters AttachmentParams
type attachmentParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
// Attachment part ID
//
// in: path
// required: true
PartID string
}
// DownloadAttachment (method: GET) returns the attachment data
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message AttachmentParams
//
// # Get message attachment
//
// This will return the attachment part using the appropriate Content-Type.
//
// The ID can be set to `latest` to reference the latest message.
//
// Produces:
// - application/*
// - image/*
// - text/*
//
// Schemes: http, https
//
// Responses:
// 200: BinaryResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
fourOFour(w)
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// swagger:parameters DownloadRawParams
type downloadRawParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
}
// DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/raw message DownloadRawParams
//
// # Get message source
//
// Returns the full email source as plain text.
//
// The ID can be set to `latest` to return the latest message source.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: TextResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}

382
server/apiv1/messages.go Normal file
View File

@@ -0,0 +1,382 @@
package apiv1
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/internal/storage"
)
// swagger:parameters GetMessagesParams
type getMessagesParams struct {
// Pagination offset
//
// in: query
// name: start
// required: false
// default: 0
// type: integer
Start int `json:"start"`
// Limit number of results
//
// in: query
// name: limit
// required: false
// default: 50
// type: integer
Limit int `json:"limit"`
}
// Summary of messages
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
// The messages summary
// in: body
Body MessagesSummary
}
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total float64 `json:"total"`
// Total number of unread messages in mailbox
Unread float64 `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count float64 `json:"count"`
// Total number of messages matching current query
MessagesCount float64 `json:"messages_count"`
// Pagination offset
Start int `json:"start"`
// All current tags
Tags []string `json:"tags"`
// Messages summary
// in: body
Messages []storage.MessageSummary `json:"messages"`
}
// GetMessages returns a paginated list of messages as JSON
func GetMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/messages messages GetMessagesParams
//
// # List messages
//
// Returns messages from the mailbox ordered from newest to oldest.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: MessagesSummaryResponse
// 400: ErrorResponse
start, beforeTS, limit := getStartLimit(r)
messages, err := storage.List(start, beforeTS, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
res.MessagesCount = stats.Total
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters SetReadStatusParams
type setReadStatusParams struct {
// in: body
Body struct {
// Read status
//
// required: false
// default: false
// example: true
Read bool
// Array of message database IDs
//
// required: false
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
IDs []string
}
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// 400: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
} else {
err := storage.MarkAllUnread()
if err != nil {
httpError(w, err.Error())
return
}
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// swagger:parameters DeleteMessagesParams
type deleteMessagesParams struct {
// Delete request
// in: body
Body struct {
// Array of message database IDs
//
// required: false
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
IDs []string
}
}
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages DeleteMessagesParams
//
// # Delete messages
//
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// 400: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil || len(data.IDs) == 0 {
if err := storage.DeleteAllMessages(); err != nil {
httpError(w, err.Error())
return
}
} else {
if err := storage.DeleteMessages(data.IDs); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// swagger:parameters SearchParams
type searchParams struct {
// Search query
//
// in: query
// required: true
// type: string
Query string `json:"query"`
// Pagination offset
//
// in: query
// required: false
// type integer
Start string `json:"start"`
// Limit results
//
// in: query
// required: false
// type integer
Limit string `json:"limit"`
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
//
// in: query
// required: false
// type string
TZ string `json:"tz"`
}
// Search returns the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/search messages SearchParams
//
// # Search messages
//
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: MessagesSummaryResponse
// 400: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
start, beforeTS, limit := getStartLimit(r)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = float64(results)
res.Unread = stats.Unread
res.Tags = stats.Tags
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters DeleteSearchParams
type deleteSearchParams struct {
// Search query
//
// in: query
// required: true
// type: string
Query string `json:"query"`
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
//
// in: query
// required: false
// type string
TZ string `json:"tz"`
}
// DeleteSearch will delete all messages matching a search
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/search messages DeleteSearchParams
//
// # Delete messages by search
//
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// 400: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

234
server/apiv1/other.go Normal file
View File

@@ -0,0 +1,234 @@
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
// swagger:parameters HTMLCheckParams
type htmlCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
}
// HTMLCheckResponse summary response
type HTMLCheckResponse = htmlcheck.Response
// HTMLCheck returns a summary of the HTML client support
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check other HTMLCheckParams
//
// # HTML check
//
// Returns the summary of the message HTML checker.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: HTMLCheckResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
fourOFour(w)
return
}
}
raw, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
e := bytes.NewReader(raw)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
msg, err := parser.ReadEnvelope(e)
if err != nil {
httpError(w, err.Error())
return
}
if msg.HTML == "" {
httpError(w, "message does not contain HTML")
return
}
checks, err := htmlcheck.RunTests(msg.HTML)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(checks); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters LinkCheckParams
type linkCheckParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
// Follow redirects
//
// in: query
// required: false
// default: false
Follow string `json:"follow"`
}
// LinkCheckResponse summary response
type LinkCheckResponse = linkcheck.Response
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check other LinkCheckParams
//
// # Link check
//
// Returns the summary of the message Link checker.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: LinkCheckResponse
// 400: ErrorResponse
// 404: NotFoundResponse
if config.DemoMode {
httpError(w, "this functionality has been disabled for demonstration purposes")
return
}
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
fourOFour(w)
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
f := r.URL.Query().Get("follow")
followRedirects := f == "true" || f == "1"
summary, err := linkcheck.RunTests(msg, followRedirects)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// swagger:parameters SpamAssassinCheckParams
type spamAssassinCheckParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
}
// SpamAssassinResponse summary response
type SpamAssassinResponse = spamassassin.Result
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/sa-check other SpamAssassinCheckParams
//
// # SpamAssassin check
//
// Returns the SpamAssassin summary (if enabled) of the message.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: SpamAssassinResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
summary, err := spamassassin.Check(msg)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}

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

@@ -0,0 +1,196 @@
package apiv1
import (
"bytes"
"encoding/json"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// swagger:parameters ReleaseMessageParams
type releaseMessageParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
// in: body
Body struct {
// Array of email addresses to relay the message to
//
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string
}
}
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessageParams
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// The ID can be set to `latest` to reference the latest message.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// 400: ErrorResponse
// 404: NotFoundResponse
if config.DemoMode {
httpError(w, "this functionality has been disabled for demonstration purposes")
return
}
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
var data struct {
To []string
}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
blocked := []string{}
notAllowed := []string{}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
notAllowed = append(notAllowed, to)
continue
}
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) {
blocked = append(blocked, to)
continue
}
}
if len(notAllowed) > 0 {
addr := tools.Plural(len(notAllowed), "Address", "Addresses")
httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", "))
return
}
if len(blocked) > 0 {
addr := tools.Plural(len(blocked), "Address", "Addresses")
httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", "))
return
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP from
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Relay(from, data.To, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -11,12 +11,13 @@ import (
"net/mail"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/jhillyerd/enmime"
)
// swagger:parameters SendMessage
// swagger:parameters SendMessageParams
type sendMessageParams struct {
// in: body
Body *SendRequest
@@ -79,23 +80,33 @@ type SendRequest struct {
Subject string
// Message body (text)
// example: This is the text body
// example: Mailpit is awesome!
Text string
// Message body (HTML)
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
// example: <div style="text-align:center"><p style="font-family: arial; font-size: 24px;">Mailpit is <b>awesome</b>!</p><p><img src="cid:mailpit-logo" /></p></div>
HTML string
// Attachments
Attachments []struct {
// Base64-encoded string of the file content
// required: true
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
// example: iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==
Content string
// Filename
// required: true
// example: AttachedFile.txt
// example: mailpit.png
Filename string
// Optional Content Type for the the attachment.
// If this field is not set (or empty) then the content type is automatically detected.
// required: false
// example: image/png
ContentType string
// Optional Content-ID (`cid`) for attachment.
// If this field is set then the file is attached inline.
// required: false
// example: mailpit-logo
ContentID string
}
// Mailpit tags
@@ -107,13 +118,6 @@ type SendRequest struct {
Headers map[string]string
}
// SendMessageConfirmation struct
type SendMessageConfirmation struct {
// Database ID
// example: iAfZVVe2UQFNSG5BAjgYwa
ID string
}
// JSONErrorMessage struct
type JSONErrorMessage struct {
// Error message
@@ -121,25 +125,46 @@ type JSONErrorMessage struct {
Error string
}
// Confirmation message for HTTP send API
// swagger:response sendMessageResponse
type sendMessageResponse struct {
// Response for sending messages via the HTTP API
//
// in: body
Body SendMessageConfirmation
}
// SendMessageConfirmation struct
type SendMessageConfirmation struct {
// Database ID
// example: iAfZVVe2UQfNSG5BAjgYwa
ID string
}
// SendMessageHandler handles HTTP requests to send a new message
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/send message SendMessage
// swagger:route POST /api/v1/send message SendMessageParams
//
// # Send a message
//
// Send a message via the HTTP API.
//
// Consumes:
// - application/json
// - application/json
//
// Produces:
// - application/json
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: sendMessageResponse
// default: jsonErrorResponse
// 200: sendMessageResponse
// 400: jsonErrorResponse
if config.DemoMode {
httpJSONError(w, "this functionality has been disabled for demonstration purposes")
return
}
decoder := json.NewDecoder(r.Body)
@@ -254,9 +279,15 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
if err != nil {
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
}
mimeType := http.DetectContentType(b)
msg = msg.AddAttachment(b, mimeType, a.Filename)
contentType := http.DetectContentType(b)
if a.ContentType != "" {
contentType = a.ContentType
}
if a.ContentID != "" {
msg = msg.AddInline(b, contentType, a.Filename, a.ContentID)
} else {
msg = msg.AddAttachment(b, contentType, a.Filename)
}
}
}
@@ -271,5 +302,5 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
return "", fmt.Errorf("error building message: %s", err.Error())
}
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes())
}

View File

@@ -1,39 +1,9 @@
package apiv1
import (
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
)
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total float64 `json:"total"`
// Total number of unread messages in mailbox
Unread float64 `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count float64 `json:"count"`
// Total number of messages matching current query
MessagesCount float64 `json:"messages_count"`
// Pagination offset
Start int `json:"start"`
// All current tags
Tags []string `json:"tags"`
// Messages summary
// in: body
Messages []storage.MessageSummary `json:"messages"`
}
// The following structs & aliases are provided for easy import
// and understanding of the JSON structure.
@@ -45,12 +15,3 @@ type Message = storage.Message
// Attachment summary
type Attachment = storage.Attachment
// HTMLCheckResponse summary
type HTMLCheckResponse = htmlcheck.Response
// LinkCheckResponse summary
type LinkCheckResponse = linkcheck.Response
// SpamAssassinResponse summary
type SpamAssassinResponse = spamassassin.Result

View File

@@ -1,161 +1,8 @@
package apiv1
import "github.com/axllent/mailpit/internal/stats"
// These structs are for the purpose of defining swagger HTTP parameters & responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
//
// in: body
Body stats.AppInformation
}
// Web UI configuration
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
//
// in: body
Body webUIConfiguration
}
// Message summary
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
// The message summary
// in: body
Body MessagesSummary
}
// Message headers
// swagger:model MessageHeaders
type messageHeaders map[string][]string
// swagger:parameters DeleteMessages
type deleteMessagesParams struct {
// in: body
Body *deleteMessagesRequestBody
}
// Delete request
// swagger:model DeleteRequest
type deleteMessagesRequestBody struct {
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters SetReadStatus
type setReadStatusParams struct {
// in: body
Body *setReadStatusRequestBody
}
// Set read status request
// swagger:model setReadStatusRequestBody
type setReadStatusRequestBody struct {
// Read status
//
// required: false
// default: false
// example: true
Read bool
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters SetTags
type setTagsParams struct {
// in: body
Body *setTagsRequestBody
}
// Set tags request
// swagger:model setTagsRequestBody
type setTagsRequestBody struct {
// Array of tag names to set
//
// required: true
// example: ["Tag 1", "Tag 2"]
Tags []string
// Array of message database IDs
//
// required: true
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters ReleaseMessage
type releaseMessageParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
// in: body
Body *releaseMessageRequestBody
}
// Release request
// swagger:model releaseMessageRequestBody
type releaseMessageRequestBody struct {
// Array of email addresses to relay the message to
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string
}
// swagger:parameters HTMLCheck
type htmlCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
}
// swagger:parameters LinkCheck
type linkCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
// Follow redirects
//
// in: query
// description: Follow redirects
// required: false
// default: false
Follow string `json:"follow"`
}
// swagger:parameters SpamAssassinCheck
type spamAssassinCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
}
// Binary data response inherits the attachment's content type.
// Binary data response which inherits the attachment's content type.
// swagger:response BinaryResponse
type binaryResponse string
@@ -167,11 +14,15 @@ type textResponse string
// swagger:response HTMLResponse
type htmlResponse string
// HTTP error response will return with a >= 400 response code
// Server error will return with a 400 status code
// with the error message in the body
// swagger:response ErrorResponse
// example: invalid request
type errorResponse string
// Not found error will return a 404 status code
// swagger:response NotFoundResponse
type notFoundResponse string
// Plain text "ok" response
// swagger:response OKResponse
type okResponse string
@@ -180,15 +31,6 @@ type okResponse string
// swagger:response ArrayResponse
type arrayResponse []string
// Confirmation message for HTTP send API
// swagger:response sendMessageResponse
type sendMessageResponse struct {
// Response for sending messages via the HTTP API
//
// in: body
Body SendMessageConfirmation
}
// JSON error response
// swagger:response jsonErrorResponse
type jsonErrorResponse struct {

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

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

207
server/apiv1/testing.go Normal file
View File

@@ -0,0 +1,207 @@
package apiv1
import (
"bytes"
"fmt"
"net/http"
"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/gorilla/mux"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// swagger:parameters GetMessageHTMLParams
type getMessageHTMLParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
// If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target="_blank"` and `rel="noreferrer noopener"` to all links.
//
// In addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.
//
// Note that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.
//
// in: query
// required: false
// type: string
Embed string `json:"embed"`
}
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /view/{ID}.html testing GetMessageHTMLParams
//
// # Render message HTML part
//
// Renders just the message's HTML part which can be used for UI integration testing.
// Attached inline images are modified to link to the API provided they exist.
// Note that is the message does not contain a HTML part then an 404 error is returned.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - text/html
//
// Schemes: http, https
//
// Responses:
// 200: HTMLResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
if msg.HTML == "" {
w.WriteHeader(404)
fmt.Fprint(w, "This message does not contain a HTML part")
return
}
htmlStr := linkInlineImages(msg)
// If embed=1 is set, then we will add target="_blank" and rel="noreferrer noopener" to all links
if r.URL.Query().Get("embed") == "1" {
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
logger.Log().Error(err.Error())
} else {
// Walk the entire HTML tree.
tools.WalkHTML(doc, func(n *html.Node) {
if n.Type == html.ElementNode && n.DataAtom == atom.A {
// Set attributes on all anchors with external links.
tools.SetHTMLAttributeVal(n, "target", "_blank")
tools.SetHTMLAttributeVal(n, "rel", "noreferrer noopener")
}
})
b := bytes.Buffer{}
_ = html.Render(&b, doc)
htmlStr = b.String()
nonce := r.Header.Get("mp-nonce")
js := `<script nonce="` + nonce + `">
if (typeof window.parent == "object") {
window.addEventListener('load', function () {
window.parent.postMessage({ messageHeight: document.body.scrollHeight}, "*")
})
}
</script>`
htmlStr = strings.ReplaceAll(htmlStr, "</body>", js+"</body>")
}
}
w.Header().Add("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(htmlStr))
}
// swagger:parameters GetMessageTextParams
type getMessageTextParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
}
// GetMessageText (method: GET) returns a message's text part
func GetMessageText(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /view/{ID}.txt testing GetMessageTextParams
//
// # Render message text part
//
// Renders just the message's text part which can be used for UI integration testing.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: TextResponse
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, "Message not found")
return
}
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(msg.Text))
}
// This will rewrite all inline image paths to API URLs
func linkInlineImages(msg *storage.Message) string {
html := msg.HTML
for _, a := range msg.Inline {
if a.ContentID != "" {
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
}
}
}
for _, a := range msg.Attachments {
if a.ContentID != "" {
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
}
}
}
return html
}

View File

@@ -22,35 +22,41 @@ var (
thumbHeight = 120
)
// swagger:parameters ThumbnailParams
type thumbnailParams struct {
// Message database ID or "latest"
//
// in: path
// required: true
ID string
// Attachment part ID
//
// in: path
// required: true
PartID string
}
// Thumbnail returns a thumbnail image for an attachment (images only)
func Thumbnail(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message ThumbnailParams
//
// # Get an attachment image thumbnail
//
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - image/jpeg
// - image/jpeg
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
// 200: BinaryResponse
// 400: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]

79
server/embed.go Normal file
View File

@@ -0,0 +1,79 @@
package server
import (
"embed"
"net/http"
"path"
"strings"
"github.com/axllent/mailpit/config"
)
var (
//go:embed ui
distFS embed.FS
)
// EmbedController is a simple controller to return a file from the embedded filesystem.
//
// This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes
// the Content-Encoding header from error responses, breaking pages such as 404's while
// using gzip compression middleware.
func embedController(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if strings.HasSuffix(p, "/") {
p = p + "index.html"
}
p = strings.TrimLeft(p, config.Webroot) // server webroot config
p = path.Join("ui", p) // add go:embed path to path prefix
b, err := distFS.ReadFile(p)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// ensure any HTML files have the correct nonce
if strings.HasSuffix(p, ".html") {
nonce := r.Header.Get("mp-nonce")
b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce))
}
// allow browser cache except for ?dev queries and HTML files
if r.URL.RawQuery != "dev" && !strings.HasSuffix(p, ".html") {
w.Header().Set("Cache-Control", "max-age=31536000, public, immutable")
}
w.Header().Set("Content-Type", contentType(p))
_, _ = w.Write(b)
}
// ContentType supports only a few content types, limited to this application's needs.
func contentType(p string) string {
switch {
case strings.HasSuffix(p, ".html"):
return "text/html; charset=utf-8"
case strings.HasSuffix(p, ".css"):
return "text/css; charset=utf-8"
case strings.HasSuffix(p, ".js"):
return "application/javascript; charset=utf-8"
case strings.HasSuffix(p, ".json"):
return "application/json"
case strings.HasSuffix(p, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(p, ".ico"):
return "image/x-icon"
case strings.HasSuffix(p, ".png"):
return "image/png"
case strings.HasSuffix(p, ".jpg"):
return "image/jpeg"
case strings.HasSuffix(p, ".gif"):
return "image/gif"
case strings.HasSuffix(p, ".woff2"):
return "font/woff2"
default:
return "text/plain"
}
}

View File

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

View File

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

View File

@@ -4,12 +4,12 @@ package server
import (
"bytes"
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"regexp"
"strings"
"sync/atomic"
"text/template"
@@ -18,20 +18,24 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/pop3"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
//go:embed ui
var embeddedFS embed.FS
var (
// AccessControlAllowOrigin CORS policy
AccessControlAllowOrigin string
// AccessControlAllowOrigin CORS policy
var AccessControlAllowOrigin string
// htmlPreviewRouteRe is a regexp to match the HTML preview route
htmlPreviewRouteRe *regexp.Regexp
)
// Listen will start the httpd
func Listen() {
@@ -39,12 +43,6 @@ func Listen() {
isReady.Store(false)
stats.Track()
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err.Error())
os.Exit(1)
}
websockets.MessageHub = websockets.NewHub()
go websockets.MessageHub.Run()
@@ -61,12 +59,12 @@ func Listen() {
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
@@ -75,11 +73,11 @@ func Listen() {
}
// UI shortcut
r.HandleFunc(config.Webroot+"view/latest", handlers.RedirectToLatestMessage).Methods("GET")
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
// frontend testing
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET")
// web UI via virtual index.html
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
@@ -111,11 +109,42 @@ func Listen() {
}
} else {
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
if err := server.ListenAndServe(); err != nil {
storage.Close()
logger.Log().Fatal(err)
socketAddr, perm, isSocket := tools.UnixSocket(config.HTTPListen)
if isSocket {
if err := tools.PrepareSocket(socketAddr); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
// delete the Unix socket file on exit
storage.AddTempFile(socketAddr)
ln, err := net.Listen("unix", socketAddr)
if err != nil {
storage.Close()
logger.Log().Fatal(err)
}
if err := os.Chmod(socketAddr, perm); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
if err := server.Serve(ln); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
} else {
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
if err := server.ListenAndServe(); err != nil {
storage.Close()
logger.Log().Fatal(err)
}
}
}
}
@@ -132,6 +161,8 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
@@ -147,6 +178,10 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
// Chaos
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
@@ -177,9 +212,28 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
// generate a new random nonce on every request
randomNonce := shortuuid.New()
// header used to pass nonce through to function
r.Header.Set("mp-nonce", randomNonce)
// Prevent JavaScript XSS by adding a nonce for script-src
cspHeader := strings.Replace(
config.ContentSecurityPolicy,
"script-src 'self';",
fmt.Sprintf("script-src 'nonce-%s';", randomNonce),
1,
)
w.Header().Set("Content-Security-Policy", cspHeader)
if htmlPreviewRouteRe == nil {
htmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\.html$`)
}
if AccessControlAllowOrigin != "" &&
(strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI)) {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
@@ -203,6 +257,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
fn(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
@@ -211,44 +266,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
}
}
// MiddlewareHandler http middleware adds optional basic authentication
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
})
}
// Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
@@ -262,7 +279,7 @@ func apiWebsocket(w http.ResponseWriter, r *http.Request) {
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
f, err := distFS.ReadFile("ui/api/v1/swagger.json")
if err != nil {
panic(err)
}
@@ -279,7 +296,7 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
}
// Just returns the default HTML template
func index(w http.ResponseWriter, _ *http.Request) {
func index(w http.ResponseWriter, r *http.Request) {
var h = `<!DOCTYPE html>
<html lang="en" class="h-100">
@@ -296,10 +313,12 @@ func index(w http.ResponseWriter, _ *http.Request) {
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
<noscript>You require JavaScript to use this app.</noscript>
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
You need a browser with JavaScript enabled to use Mailpit
</noscript>
</div>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}" nonce="{{ .Nonce }}"></script>
</body>
</html>`
@@ -312,9 +331,11 @@ func index(w http.ResponseWriter, _ *http.Request) {
data := struct {
Webroot string
Version string
Nonce string
}{
Webroot: config.Webroot,
Version: config.Version,
Nonce: r.Header.Get("mp-nonce"),
}
buff := new(bytes.Buffer)
@@ -324,6 +345,6 @@ func index(w http.ResponseWriter, _ *http.Request) {
panic(err)
}
w.Header().Add("Content-Type", "text/html")
w.Header().Add("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buff.Bytes())
}

View File

@@ -37,7 +37,7 @@ func TestAPIv1Messages(t *testing.T) {
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// check count of empty database
@@ -50,7 +50,7 @@ func TestAPIv1Messages(t *testing.T) {
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// read first 10 messages
@@ -61,17 +61,17 @@ func TestAPIv1Messages(t *testing.T) {
}
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// get RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// get headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
}
@@ -98,7 +98,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// check count of empty database
@@ -111,7 +111,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
// read first 10 IDs
@@ -134,11 +134,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
putData.IDs = putIDS
j, err := json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
@@ -147,11 +147,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
putData.Read = false
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
@@ -160,13 +160,13 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
putData.IDs = []string{}
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
t.Log("Mark all read")
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
}
@@ -243,6 +243,12 @@ func TestAPIv1Send(t *testing.T) {
{
"Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==",
"Filename": "Attached File.txt"
},
{
"Content": "iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==",
"Filename": "logo.png",
"ContentID": "inline-cid",
"ContentType": "overridden/type"
}
],
"ReplyTo": [
@@ -266,14 +272,14 @@ func TestAPIv1Send(t *testing.T) {
resp := apiv1.SendMessageConfirmation{}
if err := json.Unmarshal(b, &resp); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return
}
t.Logf("Fetching response for message %s", resp.ID)
msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
t.Logf("Testing response for message %s", resp.ID)
@@ -294,10 +300,14 @@ func TestAPIv1Send(t *testing.T) {
assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags")
assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count")
assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name")
assertEqual(t, `text/plain`, msg.Attachments[0].ContentType, "wrong Content-Type")
assertEqual(t, 1, len(msg.Inline), "wrong inline Attachment count")
assertEqual(t, `logo.png`, msg.Inline[0].FileName, "wrong Attachment name")
assertEqual(t, `overridden/type`, msg.Inline[0].ContentType, "wrong Content-Type")
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
}
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
@@ -321,12 +331,12 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
data, err := clientGet(uri)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return
}
@@ -342,12 +352,12 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
if err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
t.Error(err.Error())
return
}
@@ -383,7 +393,7 @@ func insertEmailData(t *testing.T) {
t.Fail()
}
if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}

View File

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

View File

@@ -2,7 +2,7 @@
import CommonMixins from './mixins/CommonMixins'
import Favicon from './components/Favicon.vue'
import Notifications from './components/Notifications.vue'
import { RouterView } from 'vue-router'
import EditTags from './components/EditTags.vue'
import { mailbox } from "./stores/mailbox"
export default {
@@ -11,14 +11,19 @@ export default {
components: {
Favicon,
Notifications,
EditTags
},
beforeMount() {
document.title = document.title + ' - ' + location.hostname
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
if (mailbox.uiConfig.Label) {
document.title = document.title + ' - ' + mailbox.uiConfig.Label
} else {
document.title = document.title + ' - ' + location.hostname
}
})
},
@@ -36,4 +41,5 @@ export default {
<RouterView />
<Favicon />
<Notifications />
<EditTags />
</template>

View File

@@ -1,11 +1,19 @@
import App from './App.vue'
import router from './router'
import { createApp } from 'vue'
import mitt from 'mitt';
import './assets/styles.scss'
import 'bootstrap-icons/font/bootstrap-icons.scss'
import 'bootstrap'
import 'vue-css-donut-chart/src/styles/main.css'
const app = createApp(App)
// Global event bus used to subscribe to websocket events
// such as message deletes, updates & truncation.
const eventBus = mitt()
app.provide('eventBus', eventBus)
app.use(router)
app.mount('#app')

View File

@@ -91,44 +91,6 @@
}
}
.about-mailpit {
@include media-breakpoint-down(md) {
width: var(--bs-offcanvas-width);
margin-left: -1rem !important;
}
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
opacity: 0.7;
font-weight: normal;
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
.text-spaces-nowrap {
white-space: pre;
}
@@ -266,8 +228,35 @@
}
}
.list-group-item.message:first-child {
border-top: 0;
#message-page {
.list-group-item.message:first-child {
border-top: 0;
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
color: $list-group-color;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
}
body.blur {
@@ -320,6 +309,14 @@ body.blur {
display: none;
}
.message {
&.read {
> div {
opacity: 0.7;
}
}
}
#message-view {
.form-control.dropdown {
padding: 0;

View File

@@ -26,15 +26,14 @@ export default {
},
methods: {
loadInfo: function () {
let self = this
self.get(self.resolve('/api/v1/info'), false, function (response) {
loadInfo() {
this.get(this.resolve('/api/v1/info'), false, (response) => {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
this.modal('AppInfoModal').show()
})
},
requestNotifications: function () {
requestNotifications() {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notifications")
@@ -42,11 +41,12 @@ export default {
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this
Notification.requestPermission().then(function (permission) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
mailbox.notificationsEnabled = true
}
this.modal('EnableNotificationsModal').hide()
})
}
},
@@ -56,14 +56,13 @@ export default {
<template>
<template v-if="!modals">
<div
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
<button class="text-muted btn btn-sm" v-on:click="loadInfo()">
<i class="bi bi-info-circle-fill me-1"></i>
About
</button>
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
data-bs-target="#SettingsModal" title="Mailpit UI settings">
<i class="bi bi-gear-fill"></i>
</button>
@@ -93,16 +92,20 @@ export default {
<div class="row g-3">
<div class="col-xl-6">
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
<div class="col">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
</div>
</div>
</div>
<div class="row g-3"
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
<div class="col">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
</div>
</div>
<div class="row g-3">
<div class="col-12">
@@ -146,11 +149,11 @@ export default {
</div>
</div>
<div class="col-xl-6">
<div class="card border-secondary">
<div class="card border-secondary h-100">
<div class="card-header h4">
Runtime statistics
<button class="btn btn-sm btn-outline-secondary float-end"
v-on:click="loadInfo">
v-on:click="loadInfo()">
Refresh
</button>
</div>
@@ -180,7 +183,9 @@ export default {
<td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
({{ getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) }})
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
</small>
</td>
</tr>
@@ -236,8 +241,9 @@ export default {
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="requestNotifications">Enable notifications</button>
<button type="button" class="btn btn-success" v-on:click="requestNotifications">
Enable notifications
</button>
</div>
</div>
</div>
@@ -245,6 +251,6 @@ export default {
<Settings />
</template>
<AjaxLoader :loading="loading" />
</template>

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import dayjs from 'dayjs'
import { pagination } from "../stores/pagination";
export default {
mixins: [
@@ -15,33 +16,33 @@ export default {
data() {
return {
mailbox,
pagination,
}
},
mounted() {
let relativeTime = require('dayjs/plugin/relativeTime')
created() {
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
},
mounted() {
this.refreshUI()
},
methods: {
refreshUI: function () {
let self = this
window.setTimeout(
() => {
self.$forceUpdate()
self.refreshUI()
},
30000
)
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
},
getRelativeCreated: function (message) {
let d = new Date(message.Created)
getRelativeCreated(message) {
const d = new Date(message.Created)
return dayjs(d).fromNow()
},
getPrimaryEmailTo: function (message) {
getPrimaryEmailTo(message) {
for (let i in message.To) {
return message.To[i].Address
}
@@ -49,11 +50,11 @@ export default {
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
toggleSelected(e, id) {
e.preventDefault()
if (this.isSelected(id)) {
@@ -65,7 +66,7 @@ export default {
}
},
selectRange: function (e, id) {
selectRange(e, id) {
e.preventDefault()
let selecting = false
@@ -99,6 +100,20 @@ export default {
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -110,29 +125,27 @@ export default {
:id="message.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)">
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
@click.shift="selectRange($event, message.ID)">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
<div v-if="message.From" class="overflow-x-hidden">
<div class="text-truncate privacy">
<b :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
</b>
</div>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>
<div class="overflow-x-hidden">
<div class="text-truncate text-muted small privacy">
To: {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>
</div>
</div>
</div>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
@@ -143,7 +156,8 @@ export default {
{{ message.Snippet }}
</div>
<div v-if="message.Tags.length">
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
v-on:click="pagination.start = 0"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ t }}

View File

@@ -30,30 +30,33 @@ export default {
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
reloadInbox() {
const paginationParams = this.getPaginationParams()
const reload = paginationParams?.start ? false : true
this.$router.push('/')
if (reload) {
// already on first page, reload messages
this.loadMessages()
}
},
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
markAllRead() {
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
deleteAllMessages() {
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
pagination.start = 0
self.loadMessages()
this.loadMessages()
})
}
}
@@ -62,7 +65,13 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
<div class="text-truncate fw-normal">
{{ mailbox.uiConfig.Label }}
</div>
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
@@ -74,13 +83,23 @@ export default {
</button>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
:disabled="!mailbox.unread" @click="markAllRead">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
:disabled="!mailbox.total" @click="deleteAllMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
@@ -114,7 +133,8 @@ export default {
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -122,8 +142,8 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }}
message<span v-if="mailbox.count > 1">s</span>.
This will permanently delete {{ formatNumber(mailbox.total) }}
message<span v-if="mailbox.total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>

View File

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

View File

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

View File

@@ -6,44 +6,28 @@ import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
// if the current filter is active then reload view
reloadFilter: function (tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`^tag:"?${tag}"?$`, 'i')
if (query.match(re)) {
pagination.start = 0
this.$emit('loadMessages')
}
},
// test whether a tag is currently being searched for (in the URL)
inSearch: function (tag) {
inSearch(tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
return query.match(re)
},
// toggle a tag search in the search URL, add or remove it accordingly
toggleTag: function (e, tag) {
toggleTag(e, tag) {
e.preventDefault()
const urlParams = new URLSearchParams(window.location.search)
@@ -67,9 +51,28 @@ export default {
if (query == '') {
this.$router.push('/')
} else {
this.$router.push('/search?q=' + encodeURIComponent(query))
const params = new URLSearchParams({
q: query,
start: pagination.start.toString(),
limit: pagination.limit.toString(),
})
this.$router.push('/search?' + params.toString())
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -77,10 +80,15 @@ export default {
<template>
<template v-if="mailbox.tags && mailbox.tags.length">
<div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
Edit tags
</button>
</li>
<li>
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
<template v-if="mailbox.showTagColors">Hide</template>
@@ -90,9 +98,9 @@ export default {
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
v-on:click="reloadFilter(tag)" v-on:click.ctrl="toggleTag($event, tag)"
<div class="list-group mt-1 mb-2">
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>

View File

@@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
pagination,
@@ -14,33 +17,38 @@ export default {
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false
version: false,
clientErrors: [], // errors received via websocket
}
},
mounted() {
let d = document.getElementById('app')
const d = document.getElementById('app')
if (d) {
this.version = d.dataset.version
}
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.socketBreakReset()
this.connect()
mailbox.notificationsSupported = window.isSecureContext
&& ("Notification" in window && Notification.permission !== "denied")
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
this.errorNotificationCron()
},
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
connect() {
const ws = new WebSocket(this.socketURI)
ws.onmessage = (e) => {
let response
try {
response = JSON.parse(e.data)
@@ -50,18 +58,7 @@ export default {
// new messages
if (response.Type == "new" && response.Data) {
if (!mailbox.searching) {
if (pagination.start < 1) {
// push results directly into first page
mailbox.messages.unshift(response.Data)
if (mailbox.messages.length > pagination.limit) {
mailbox.messages.pop()
}
} else {
// update pagination offset
pagination.start++
}
}
this.eventBus.emit("new", response.Data)
for (let i in response.Data.Tags) {
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
@@ -73,46 +70,84 @@ export default {
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
if (!this.pauseNotifications) {
this.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
this.browserNotify("New mail from: " + from, response.Data.Subject)
this.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
window.scrollInPlace = true
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
this.eventBus.emit("prune");
} else if (response.Type == "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total
mailbox.unread = response.Data.Unread
// detect version updated, refresh is needed
if (self.version != response.Data.Version) {
if (this.version != response.Data.Version) {
location.reload()
}
} else if (response.Type == "delete" && response.Data) {
// broadcast for components
this.eventBus.emit("delete", response.Data)
} else if (response.Type == "update" && response.Data) {
// broadcast for components
this.eventBus.emit("update", response.Data)
} else if (response.Type == "truncate") {
// broadcast for components
this.eventBus.emit("truncate")
} else if (response.Type == "error") {
// broadcast for components
this.addClientError(response.Data)
}
}
ws.onopen = function () {
ws.onopen = () => {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
this.socketLastConnection = Date.now()
if (this.reconnectRefresh) {
this.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
ws.onclose = (e) => {
if (this.socketLastConnection == 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log('Unable to connect to websocket, disabling websocket support')
return
}
setTimeout(function () {
self.connect() // reconnect
if (mailbox.connected) {
// count disconnections
this.socketBreaks++
}
// set disconnected state
mailbox.connected = false
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log('Unstable websocket connection, disabling websocket support')
return
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true
} else {
this.reconnectRefresh = false
}
setTimeout(() => {
this.connect() // reconnect
}, 1000)
}
@@ -121,13 +156,19 @@ export default {
}
},
browserNotify: function (title, message) {
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0
this.socketBreakReset()
}, 15000)
},
browserNotify(title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
@@ -136,7 +177,7 @@ export default {
}
},
setMessageToast: function (m) {
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
@@ -144,29 +185,59 @@ export default {
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
const el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
this.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
closeToast() {
const el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}
},
addClientError(d) {
d.expire = Date.now() + 5000 // expire after 5s
this.clientErrors.push(d)
},
errorNotificationCron() {
window.setTimeout(() => {
this.clientErrors.forEach((err, idx) => {
if (err.expire < Date.now()) {
this.clientErrors.splice(idx, 1)
}
})
this.errorNotificationCron()
}, 1000)
}
},
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
</svg>
<strong class="me-auto">{{ error.Type }}</strong>
<small class="text-body-secondary">{{ error.IP }}</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
{{ error.Message }}
</div>
</div>
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" v-if="toastMessage">
<i class="bi bi-envelope-exclamation-fill me-2"></i>

View File

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

View File

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

View File

@@ -12,18 +12,36 @@ export default {
mailbox,
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
timezones,
chaosConfig: false,
chaosUpdated: false,
}
},
watch: {
theme: function(v) {
theme(v) {
if (v == 'auto') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', v)
}
this.setTheme()
},
chaosConfig: {
handler() {
this.chaosUpdated = true
},
deep: true
},
'mailbox.skipConfirmations'(v) {
if (v) {
localStorage.setItem('skip-confirmations', 'true')
} else {
localStorage.removeItem('skip-confirmations')
}
}
},
mounted() {
@@ -31,10 +49,12 @@ export default {
this.$nextTick(function () {
Tags.init('select.tz')
})
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
},
methods: {
setTheme: function () {
setTheme() {
if (
this.theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -44,6 +64,24 @@ export default {
document.documentElement.setAttribute('data-bs-theme', this.theme)
}
},
loadChaos() {
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
this.chaosConfig = response.data
this.$nextTick(() => {
this.chaosUpdated = false
})
})
},
saveChaos() {
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
this.chaosConfig = response.data
this.$nextTick(() => {
this.chaosUpdated = false
})
})
}
}
}
</script>
@@ -54,64 +92,199 @@ export default {
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="SettingsModalLabel">Mailpit UI settings</h5>
<h5 class="modal-title" id="SettingsModalLabel">Mailpit settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="theme" class="form-label">Mailpit theme</label>
<select class="form-select" v-model="theme" id="theme">
<option value="auto">Auto (detect from browser)</option>
<option value="light">Light theme</option>
<option value="dark">Dark theme</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone">
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
</select>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
v-model="mailbox.showTagColors">
<label class="form-check-label" for="tagColors">
Use auto-generated tag colors
</label>
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
aria-selected="true">Web UI</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
aria-selected="false" @click="loadChaos">Chaos</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
tabindex="0">
<div class="my-3">
<label for="theme" class="form-label">Mailpit theme</label>
<select class="form-select" v-model="theme" id="theme">
<option value="auto">Auto (detect from browser)</option>
<option value="light">Light theme</option>
<option value="dark">Dark theme</option>
</select>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone"
data-allow-same="true">
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
</select>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
v-model="mailbox.showTagColors">
<label class="form-check-label" for="tagColors">
Use auto-generated tag colors
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
v-model="mailbox.showHTMLCheck">
<label class="form-check-label" for="htmlCheck">
Show HTML check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
v-model="mailbox.showLinkCheck">
<label class="form-check-label" for="linkCheck">
Show link check message tab
</label>
</div>
</div>
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
v-model="mailbox.showSpamCheck">
<label class="form-check-label" for="spamCheck">
Show spam check message tab
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="skip-confirmations" v-model="mailbox.skipConfirmations">
<label class="form-check-label" for="skip-confirmations">
Skip <code>Delete all</code> &amp; <code>Mark all read</code> confirmation
dialogs
</label>
</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
v-model="mailbox.showHTMLCheck">
<label class="form-check-label" for="htmlCheck">
Show HTML check message tab
</label>
<div class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
<p class="my-3">
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
stages in a SMTP transaction to test application resilience
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
see documentation
</a>).
</p>
<ul>
<li>
<code>Response code</code> is the SMTP error code returned by the server if this
error is triggered. Error codes must range between 400 and 599.
</li>
<li>
<code>Error probability</code> is the % chance that the error will occur per message
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
trigger. A probability of <code>50</code> will trigger on approximately 50% of
messages received.
</li>
</ul>
<template v-if="chaosConfig">
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
<div class="mb-4">
<label>Trigger: <code>Sender</code></label>
<div class="form-text">
Trigger an error response based on the sender (From / Sender).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Sender.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Sender.Probability">
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Recipient</code></label>
<div class="form-text">
Trigger an error response based on the recipients (To, Cc, Bcc).
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Recipient.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Recipient.Probability">
</div>
</div>
</div>
<div class="mb-4">
<label>Trigger: <code>Authentication</code></label>
<div class="form-text">
Trigger an authentication error response.
Note that SMTP authentication must be configured too.
</div>
<div class="row mt-1">
<div class="col">
<label class="form-label">
Response code
</label>
<input type="number" class="form-control"
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
max="599" required>
</div>
<div class="col">
<label class="form-label">
Error probability ({{ chaosConfig.Authentication.Probability }}%)
</label>
<input type="range" class="form-range mt-1" min="0" max="100"
v-model.number="chaosConfig.Authentication.Probability">
</div>
</div>
</div>
</div>
<div v-if="chaosUpdated" class="mb-3 text-center">
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
</div>
</template>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
v-model="mailbox.showLinkCheck">
<label class="form-check-label" for="linkCheck">
Show link check message tab
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
v-model="mailbox.showSpamCheck">
<label class="form-check-label" for="spamCheck">
Show spam check message tab
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>

View File

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

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