Compare commits

..

172 Commits

Author SHA1 Message Date
Ralph Slooten
a1c34b37e1 Merge branch 'release/v1.6.22' 2023-06-26 17:32:13 +12:00
Ralph Slooten
e37583073e Release v1.6.22 2023-06-26 17:32:12 +12:00
Ralph Slooten
4de830c490 Update Go modules 2023-06-26 17:29:31 +12:00
Ralph Slooten
22a4509b13 Feature: Clearer SMTP error messages 2023-06-26 17:27:41 +12:00
Ralph Slooten
1ed06161a8 Libs: Update Go modules 2023-06-19 20:22:02 +12:00
Ralph Slooten
a7ee479f06 Libs: Upgrade node modules
Includes changes required for bootstrap5-tags
2023-06-19 16:27:57 +12:00
Ralph Slooten
93e8884ef7 Merge tag 'v1.6.21' into develop
Release v1.6.21
2023-06-15 22:25:50 +12:00
Ralph Slooten
1c228cda56 Merge branch 'release/v1.6.21' 2023-06-15 22:25:48 +12:00
Ralph Slooten
119b3864b2 Release v1.6.21 2023-06-15 22:25:47 +12:00
Ralph Slooten
b9f035790d UI: More accurate clickable hyperlink logic in plain text messages
See #125
2023-06-15 22:07:29 +12:00
Ralph Slooten
1260c2e6df Merge tag 'v1.6.20' into develop
Release v1.6.20
2023-06-15 17:34:17 +12:00
Ralph Slooten
3431f18a3f Merge branch 'release/v1.6.20' 2023-06-15 17:34:15 +12:00
Ralph Slooten
2fa5138b49 Release v1.6.20 2023-06-15 17:34:15 +12:00
Ralph Slooten
652fec0f64 Merge branch 'feature/text-clickable-links' into develop 2023-06-15 17:24:42 +12:00
Ralph Slooten
f168e11b05 Libs: Update node modules 2023-06-15 17:24:32 +12:00
Ralph Slooten
35e81e0336 Feature: Convert links into clickable hyperlinks in plain text message content
@see 125
2023-06-15 17:15:46 +12:00
Ralph Slooten
7beed988e5 Merge tag 'v1.6.19' into develop
Release v1.6.19
2023-06-15 09:52:12 +12:00
Ralph Slooten
4eea79f0c8 Merge branch 'release/v1.6.19' 2023-06-15 09:52:10 +12:00
Ralph Slooten
39767e979c Release v1.6.19 2023-06-15 09:52:10 +12:00
Ralph Slooten
4e2f02ee0a Fix: Only display sendmail help when sendmail subcommand is invoked
This was overriding all Mailpit's help commands
2023-06-15 09:50:11 +12:00
Ralph Slooten
5a04534314 Add :key to message in message list 2023-06-15 09:27:26 +12:00
Ralph Slooten
6725a809d5 Increase auto-build package upload retries from 3 to 5 2023-06-14 22:33:36 +12:00
Ralph Slooten
64a067cff9 Merge tag 'v1.6.18' into develop
Release v1.6.18
2023-06-14 22:20:48 +12:00
Ralph Slooten
58dbccc0a7 Merge branch 'release/v1.6.18' 2023-06-14 22:20:40 +12:00
Ralph Slooten
3ef320d277 Release v1.6.18 2023-06-14 22:20:40 +12:00
Ralph Slooten
18e95b699e Merge branch 'feature/labels' into develop 2023-06-14 22:18:59 +12:00
Ralph Slooten
fc89655b7f UI: Add option to enable tag colors based on tag name hash
An experimental option to add tag colors (see #127). This will generate a random color for each unique tag
2023-06-14 22:18:51 +12:00
Ralph Slooten
ff9a6ff491 API: Sort tags before saving
Tags should be alphabetically sorted before saving. Whilst this was the behavior with automated tagging, it did not apply to manually set tags via the API.
2023-06-13 16:57:42 +12:00
Ralph Slooten
adce75ab8f UI: Display message tags below subject in message overview 2023-06-13 15:53:18 +12:00
Ralph Slooten
12903cae60 Merge tag 'v1.6.17' into develop
Release v1.6.17
2023-06-07 07:36:23 +12:00
Ralph Slooten
7f55511c82 Merge branch 'release/v1.6.17' 2023-06-07 07:36:21 +12:00
Ralph Slooten
309036fb6d Release v1.6.17 2023-06-07 07:36:20 +12:00
Ralph Slooten
48387c3a13 Rename hidden long flags 2023-06-07 07:35:34 +12:00
Ronaldo Richieri
a2ab350aff Fix: Add single dash arguments support to sendmail command (#123)
mailpit sendmail -t was not working because of the single dash argument
parsing. This commit fixes it.

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 21:12:36 +13:00
Ralph Slooten
e188325ddd Merge tag 'v1.5.2' into develop
Release v1.5.2
2023-04-01 17:08:12 +13:00
47 changed files with 3054 additions and 2816 deletions

View File

@@ -22,6 +22,13 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Parse semver
id: semver_parser
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
with:
input_string: '${{ github.ref_name }}'
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v4
with:
@@ -31,4 +38,7 @@ jobs:
build-args: |
"VERSION=${{ github.ref_name }}"
push: true
tags: axllent/mailpit:latest,axllent/mailpit:${{ github.ref_name }}
tags: |
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}

View File

@@ -10,13 +10,13 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v7.0.0
- uses: actions/stale@v8.0.0
with:
days-before-issue-stale: 30
days-before-issue-stale: 21
days-before-issue-close: 7
exempt-issue-labels: "enhancement,bug"
exempt-issue-labels: "enhancement,bug,javascript,docker"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
stale-issue-message: "This issue is stale because it has been open for 21 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1

View File

@@ -33,7 +33,7 @@ jobs:
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.36
- uses: wangyoucao577/go-release-action@v1.38
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
@@ -44,4 +44,5 @@ jobs:
extra_files: LICENSE README.md
md5sum: false
overwrite: true
retry: 5
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"

View File

@@ -12,7 +12,7 @@ jobs:
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/node_modules/
/send
/sendmail/sendmail
/server/ui/dist
/Makefile
/mailpit*

View File

@@ -2,6 +2,229 @@
Notable changes to Mailpit will be documented in this file.
## [v1.6.22]
### Feature
- Clearer SMTP error messages
### Libs
- Update Go modules
- Upgrade node modules
## [v1.6.21]
### UI
- More accurate clickable hyperlink logic in plain text messages
## [v1.6.20]
### Feature
- Convert links into clickable hyperlinks in plain text message content
### Libs
- Update node modules
## [v1.6.19]
### Fix
- Only display sendmail help when sendmail subcommand is invoked
## [v1.6.18]
### API
- Sort tags before saving
### UI
- Add option to enable tag colors based on tag name hash
- Display message tags below subject in message overview
## [v1.6.17]
### Fix
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
## [v1.6.16]
### Bugfix
- Fix sendmail/startup panic
## [v1.6.15]
### Feature
- Add `sendmail -bs` functionality
## [v1.6.14]
### Feature
- Add ability to delete or mark search results read
- Set tags via X-Tags message header
### Libs
- Update node modules
## [v1.6.13]
### Feature
- Add SMTP LOGIN authentication method for message relay
## [v1.6.12]
### Feature
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
### Swagger
- Update swagger field descriptions, add MessageID
## [v1.6.11]
### Libs
- Update node modules
- Update Go modules
### UI
- Check for secure context instead of HTTPS ([#114](https://github.com/axllent/mailpit/issues/114))
## [v1.6.10]
### Libs
- Update node modules
- Update Go modules
### UI
- Remove "Noto Color Emoji" from default bootstrap font list
## [v1.6.9]
### API
- Return blank 200 response for OPTIONS requests (CORS)
### Bugfix
- Correctly escape JS cid regex
### Libs
- Update node modules
- Update Go modules
## [v1.6.8]
### Bugfix
- Fix Date display when message doesn't contain a Date header
### Feature
- Add allowlist to filter recipients before relaying messages ([#109](https://github.com/axllent/mailpit/issues/109))
- Add `-S` short flag for sendmail `--smtp-addr`
## [v1.6.7]
### Bugfix
- Fix auto-deletion cron
## [v1.6.6]
### API
- Set Access-Control-Allow-Headers when --api-cors is set
- Include correct start value in search reponse
### Feature
- Option to ignore duplicate Message-IDs
### Libs
- Update node modules
- Update Go modules
### Swagger
- Update swagger field descriptions
### UI
- Style Undisclosed recipients in message view
## [v1.6.5]
### Feature
- Add Access-Control-Allow-Methods methods when CORS origin is set
## [v1.6.4]
### Bugfix
- Fix UI images not displaying when multiple cid names overlap
## [v1.6.3]
### Feature
- Display clickable toast notifications for new messages
## [v1.6.2]
### Bugfix
- If set use return-path address as SMTP from address
## [v1.6.1]
### Bugfix
- Add API release route again (bad merge)
## [v1.6.0]
### API
- Enable cross-origin resource sharing (CORS) configuration
- Message relay / release
- Include Return-Path in message summary data
### Feature
- Inject/update Bcc header for missing addresses when SMTP recipients do not match messsage headers
### Libs
- Update Go modules
- Update node modules
### UI
- Display Return-Path if different to the From address
- Message release functionality
## [v1.5.5]
### Docker
- Add Docker image tag for major/minor version
### Feature
- Update listen regex to allow IPv6 addresses ([#85](https://github.com/axllent/mailpit/issues/85))
## [v1.5.4]
### Feature
- Mobile and tablet HTML preview toggle in desktop mode
## [v1.5.3]
### Bugfix
- Enable SMTP auth flags to be set via env
## [v1.5.2]
### API

View File

@@ -20,14 +20,15 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
- Mobile and tablet HTML preview toggle in desktop mode
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging))
- Real-time web UI updates using web sockets for new mail
- Optional browser notifications for new mail (HTTPS only)
- Optional browser notifications for new mail (HTTPS and `localhost` only)
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size
- Can handle tens of thousands of emails
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size, easily handling tens of thousands of emails
- SMTP relaying / message release - relay messages via a different SMTP server including an optional allowlist of accepted recipients ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
- Optional SMTP with STARTTLS & SMTP authentication, including an "accept anything" mode ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
@@ -71,26 +72,20 @@ See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images)
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
### Testing Mailpit
Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Testing-Mailpit) of how to easily test email delivery to Mailpit.
### Configuring sendmail
There are several different options available:
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
```
sendmail_path = /usr/local/bin/mailpit sendmail
```
If Mailpit is found on the same host as sendmail, you can symlink the Mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if Mailpit is running on default 1025 port).
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
Mailpit's SMTP server (by default on port 1025), so you will likely need to configure your sending application to deliver mail via that port. A common MTA (Mail Transfer Agent) that delivers system emails to a SMTP server is `sendmail`, used by many applications including PHP. Mailpit can also act as substitute for sendmail. For instructions of how to set this up, please refer to the [sendmail documentation](https://github.com/axllent/mailpit/wiki/Configuring-sendmail).
## Why rewrite MailHog?
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of performance issues, many of the frontend and Go modules are horribly out of date, and it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects) and has too many unnecessary features for my purpose. It performs exceptionally poorly when dealing with large amounts of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute to ingest). The API also transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
Initially I tried to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect to its authors) poorly designed. It is in my opinion over-engineered (split over 9 separate projects), and performs very poorly when dealing with large amounts of emails or processing emails with an attachments (a single email with a 3MB attachment can take over a minute to ingest). Finally, the API transmits a lot of duplicate and unnecessary data on every browser request, and there is no HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

View File

@@ -85,21 +85,26 @@ func init() {
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", false, "Use message dates as the received dates")
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", false, "Accept any SMTP username and password, including none")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", false, "Enable insecure PLAIN & LOGIN authentication")
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
rootCmd.Flags().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
// deprecated flags 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
@@ -179,20 +184,32 @@ func initConfigFromEnv() {
config.SMTPAuthAllowInsecure = true
}
// Relay server config
if len(os.Getenv("MP_SMTP_RELAY_CONFIG")) > 0 {
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
}
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAllIncoming = true
}
// Misc options
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
config.IgnoreDuplicateIDs = true
}
if getEnabledFromEnv("MP_QUIET") {
config.QuietLogging = true
logger.QuietLogging = true
}
if getEnabledFromEnv("MP_VERBOSE") {
config.VerboseLogging = true
logger.VerboseLogging = true
}
}

View File

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

View File

@@ -10,16 +10,19 @@ import (
"regexp"
"strings"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
"gopkg.in/yaml.v3"
)
var (
// SMTPListen to listen on <interface>:<port>
SMTPListen = "0.0.0.0:1025"
SMTPListen = "[::]:1025"
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
HTTPListen = "[::]:8025"
// DataFile for mail (optional)
DataFile string
@@ -30,15 +33,6 @@ var (
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
// VerboseLogging for console output
VerboseLogging = false
// QuietLogging for console output (errors only)
QuietLogging = false
// NoLogging for tests
NoLogging = false
// UITLSCert file
UITLSCert string
@@ -48,7 +42,7 @@ var (
// UIAuthFile for basic authentication
UIAuthFile string
// UIAuth used for euthentication
// UIAuth used for authentication
UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
@@ -63,8 +57,8 @@ var (
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuth used for euthentication
SMTPAuth *htpasswd.File
// SMTPAuthConfig used for authentication auto-generated from SMTPAuthFile
SMTPAuthConfig *htpasswd.File
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
@@ -72,14 +66,30 @@ var (
// SMTPAuthAcceptAny accepts any username/password including none
SMTPAuthAcceptAny bool
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// TagRegexp is the allowed tag characters
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []Tag
SMTPTags []AutoTag
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
SMTPRelayConfig smtpRelayConfigStruct
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAllIncoming = false
// ContentSecurityPolicy for HTTP server
ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';"
@@ -94,19 +104,34 @@ var (
RepoBinaryName = "mailpit"
)
// Tag struct
type Tag struct {
// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
Match string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
type smtpRelayConfigStruct struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
RecipientAllowlistRegexp *regexp.Regexp
}
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
}
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
re := regexp.MustCompile(`^((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(\[([\da-fA-F:])+\])):\d+$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
}
@@ -167,7 +192,7 @@ func VerifyConfig() error {
if err != nil {
return err
}
SMTPAuth = a
SMTPAuthConfig = a
}
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
@@ -182,7 +207,7 @@ func VerifyConfig() error {
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
SMTPTags = []Tag{}
SMTPTags = []AutoTag{}
p := shellwords.NewParser()
@@ -195,19 +220,99 @@ func VerifyConfig() error {
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := strings.TrimSpace(t[0])
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
tag := tools.CleanTag(t[0])
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
if len(match) == 0 {
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, Tag{Tag: tag, Match: match})
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("Error parsing tags (%s)", a)
}
}
}
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
return err
}
if !ReleaseEnabled && SMTPRelayAllIncoming {
return errors.New("SMTP relay config must be set to relay all messages")
}
if SMTPRelayAllIncoming {
// this deserves a warning
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
return nil
}
// Parse & validate the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
if !isFile(c) {
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("SMTP relay host not set")
}
if SMTPRelayConfig.Port == 0 {
SMTPRelayConfig.Port = 25 // default
}
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
SMTPRelayConfig.Auth = "none"
} else if SMTPRelayConfig.Auth == "plain" {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%s)", c)
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for LOGIN authentication (%s)", c)
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
return fmt.Errorf("SMTP relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
}
} else {
return fmt.Errorf("SMTP relay authentication method not supported: %s", SMTPRelayConfig.Auth)
}
ReleaseEnabled = true
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
if SMTPRelayConfig.RecipientAllowlist != "" {
if err != nil {
return fmt.Errorf("failed to compile recipient allowlist regexp: %e", err)
}
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
logger.Log().Infof("[smtp] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
}

View File

@@ -15,6 +15,7 @@ Returns a JSON summary of the message and attachments.
```json
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"Read": true,
"From": {
"Name": "John Doe",
@@ -31,6 +32,7 @@ Returns a JSON summary of the message and attachments.
"ReplyTo": [],
"Subject": "Message subject",
"Date": "2016-09-07T16:46:00+13:00",
"Tags": ["test"],
"Text": "Plain text MIME part of the email",
"HTML": "HTML MIME part (if exists)",
"Size": 79499,

View File

@@ -31,9 +31,11 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"unread": 500,
"count": 50,
"start": 0,
"tags": ["test"],
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
@@ -54,6 +56,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"Bcc": [],
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Tags": ["test"],
"Size": 6144,
"Attachments": 0
},

View File

@@ -30,6 +30,7 @@ Matching messages are returned in the order of latest received to oldest.
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",

41
go.mod
View File

@@ -8,19 +8,21 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.11.1
github.com/k3a/html2text v1.1.0
github.com/klauspost/compress v1.16.3
github.com/leporo/sqlf v1.3.0
github.com/jhillyerd/enmime v1.0.0
github.com/k3a/html2text v1.2.1
github.com/klauspost/compress v1.16.6
github.com/leporo/sqlf v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.6.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.1
golang.org/x/text v0.8.0
modernc.org/sqlite v1.21.1
golang.org/x/text v0.10.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.23.1
)
require (
@@ -35,27 +37,28 @@ require (
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/image v0.6.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/tools v0.7.0 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/image v0.8.0 // indirect
golang.org/x/mod v0.11.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/tools v0.10.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.14 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect

93
go.sum
View File

@@ -53,21 +53,20 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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 v0.11.1 h1:U6ToGVxfxNQQhKrAaGxtwOf7Zqksb8AQ3j1CyAWOk5k=
github.com/jhillyerd/enmime v0.11.1/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
github.com/jhillyerd/enmime v1.0.0 h1:8swYgO1fm68PllCKz5jiLzgD3axNUS388jr6BtRSsl8=
github.com/jhillyerd/enmime v1.0.0/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.1.0 h1:ks4hKSTdiTRsLr0DM771mI5TvsoG6zH7m1Ulv7eJRHw=
github.com/k3a/html2text v1.1.0/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -75,17 +74,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leporo/sqlf v1.3.0 h1:nAkuPYxMIJg/sUmcd1h4avV5iYo8tBTGEGOIR4BIZO8=
github.com/leporo/sqlf v1.3.0/go.mod h1:f4dHqIi1+nLl6k1IsNQ8QIEbGWK49th2ei1IxTXk+2E=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -94,6 +93,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -105,21 +108,26 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -127,26 +135,26 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
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.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
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.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-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.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -154,8 +162,9 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -163,15 +172,15 @@ 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.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
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=
@@ -183,25 +192,25 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ=
modernc.org/ccgo/v3 v3.16.14/go.mod h1:mPDSujUIaTNWQSG4eqKw+atqLOEbma6Ncsa94WbC9zo=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

2937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
"axios": "^1.2.1",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.4.41",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",

View File

@@ -1,9 +1,18 @@
// Package cmd is the sendmail cli
package cmd
/**
* Bare bones sendmail drop-in replacement borrowed from MailHog
*
* It uses a bit of a hack for flag parsing in order to be compatible
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
* is not POSIX compatible.
*
* The -bs command-line switch causes sendmail to run a single SMTP session in the
* foreground over its standard input and output, and then exit. The SMTP session
* is exactly like a network SMTP session. Usually, one or more messages are
* submitted to sendmail for delivery.
*/
import (
"bytes"
"fmt"
@@ -12,13 +21,27 @@ import (
"net/smtp"
"os"
"os/user"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/reiver/go-telnet"
flag "github.com/spf13/pflag"
)
// Run the Mailpit sendmail replacement.
func Run() {
var (
// SMTPAddr address
SMTPAddr = "localhost:1025"
// FromAddr email address
FromAddr string
// UseB - used to set from `-bs`
UseB bool
// UseS - used to set from `-bs`
UseS bool
)
func init() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
@@ -30,37 +53,68 @@ func Run() {
username = user.Username
}
fromAddr := username + "@" + host
smtpAddr := "localhost:1025"
var recip []string
if FromAddr == "" {
FromAddr = username + "@" + host
}
}
// Run the Mailpit sendmail replacement.
func Run() {
var recipients []string
// defaults from envars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
}
var verbose bool
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolP("verbose", "v", false, "Ignored")
flag.BoolP("long-i", "i", false, "Ignored")
flag.BoolP("long-o", "o", false, "Ignored")
flag.BoolP("long-t", "t", false, "Ignored")
// set the default help
flag.Usage = func() {
fmt.Println(HelpTemplate(os.Args[0:1]))
}
var showHelp bool
// avoid 'pflag: help requested' error
flag.BoolVarP(&showHelp, "help", "h", false, "")
// override defaults from cli flags
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
flag.BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
flag.CommandLine.SortFlags = false
flag.Parse()
// allow recipient to be passed as an argument
recip = flag.Args()
// allow recipients to be passed as an argument
recipients = flag.Args()
if verbose {
fmt.Fprintln(os.Stderr, smtpAddr, fromAddr)
if showHelp {
flag.Usage()
os.Exit(0)
}
// ensure -bs is set
if UseB && !UseS || !UseB && UseS {
fmt.Printf("error: use -bs")
os.Exit(1)
}
// handles `sendmail -bs`
if UseB && UseS {
var caller telnet.Caller = telnet.StandardCaller
// telnet directly to SMTP
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
return
}
body, err := ioutil.ReadAll(os.Stdin)
@@ -75,15 +129,51 @@ func Run() {
os.Exit(11)
}
if len(recip) == 0 {
// We only need to parse the message to get a recipient if none where
// provided on the command line.
recip = append(recip, msg.Header.Get("To"))
addresses := []string{}
if len(recipients) > 0 {
addresses = recipients
} else {
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
addresses = append(addresses, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
addresses = append(addresses, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
addresses = append(addresses, a.Address)
}
}
}
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
err = smtp.SendMail(SMTPAddr, nil, FromAddr, addresses, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
}
}
// HelpTemplate returns a string of the help
func HelpTemplate(args []string) string {
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
Usage: %s [flags] [recipients] < message
See: https://github.com/axllent/mailpit
Flags:
-S string SMTP server address (default "localhost:1025")
-f string Set the envelope sender address (default "%s")
-bs Handle SMTP commands on standard input
-t Ignored
-i Ignored
-o Ignored
-v Ignored
`, config.Version, strings.Join(args, " "), FromAddr)
}

View File

@@ -10,8 +10,12 @@ import (
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/gorilla/mux"
uuid "github.com/satori/go.uuid"
)
// GetMessages returns a paginated list of messages as JSON
@@ -30,13 +34,13 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: start
// in: query
// description: pagination offset
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: limit results
// description: Limit results
// required: false
// type: integer
// default: 50
@@ -84,12 +88,12 @@ func Search(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: query
// in: query
// description: search query
// description: Search query
// required: true
// type: string
// + name: limit
// in: query
// description: limit results
// description: Limit results
// required: false
// type: integer
// default: 50
@@ -115,7 +119,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
var res MessagesSummary
res.Start = 0
res.Start = start
res.Messages = messages
res.Count = len(messages)
res.Total = stats.Total
@@ -143,7 +147,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
//
@@ -184,12 +188,12 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: attachment part id
// description: Attachment part ID
// required: true
// type: string
//
@@ -233,7 +237,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
//
@@ -280,7 +284,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
//
@@ -326,7 +330,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message ids to delete
// description: Database IDs to delete
// required: false
// type: DeleteRequest
//
@@ -377,7 +381,7 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message ids to update
// description: Database IDs to update
// required: false
// type: SetReadStatusRequest
//
@@ -455,7 +459,7 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message ids to update
// description: Database IDs to update
// required: true
// type: SetTagsRequest
//
@@ -491,6 +495,133 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// ReleaseMessage (method: POST) will release a message via a preconfigured external SMTP server.
// If no IDs are provided then all messages are updated.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message Release
//
// # Release message
//
// Release a message via a pre-configured external SMTP server..
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: to
// in: body
// description: Array of email addresses to release message to
// required: true
// type: ReleaseMessageRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequest{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
tos := data.To
if len(tos) == 0 {
httpError(w, "No valid addresses found")
return
}
for _, to := range tos {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
httpError(w, "Mail address does not match allowlist: "+to)
return
}
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
froms, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
from := froms[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc", "Message-Id"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP mfrom
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// generate unique ID
uid := uuid.NewV4().String() + "@mailpit"
// add unique ID
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
if err := smtpd.Send(from, tos, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
@@ -526,3 +657,10 @@ func getStartLimit(req *http.Request) (start int, limit int) {
return start, limit
}
// GetOptions returns a blank response
func GetOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(""))
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/axllent/mailpit/utils/updater"
)
// Response includes the current and latest Mailpit versions, database info, and memory usage
// Response includes the current and latest Mailpit version, database info, and memory usage
//
// swagger:model AppInformation
type appInformation struct {
@@ -33,12 +33,12 @@ type appInformation struct {
func AppInfo(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get the application information
// # Get application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/octet-stream
// - application/json
//
// Schemes: http, https
//

View File

@@ -9,6 +9,13 @@ type infoResponse struct {
Body appInformation
}
// Web UI configuration
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
Body webUIConfiguration
}
// Message summary
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
@@ -47,11 +54,19 @@ type setTagsRequest struct {
// in:body
Tags []string `json:"tags"`
// ids
// IDs
// in:body
IDs []string `json:"ids"`
}
// Release request
// swagger:model ReleaseMessageRequest
type releaseMessageRequest struct {
// To
// in:body
To []string `json:"to"`
}
// Binary data reponse inherits the attachment's content type
// swagger:response BinaryResponse
type binaryResponse struct {

View File

@@ -39,12 +39,12 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: attachment part id
// description: Attachment part ID
// required: true
// type: string
//

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

@@ -0,0 +1,57 @@
package apiv1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/axllent/mailpit/config"
)
// Response includes global web UI settings
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
Enabled bool
// The configured SMTP server address
SMTPServer string
// Enforced Return-Path (if set) for relay bounces
ReturnPath string
// Allowlist of accepted recipients
RecipientAllowlist string
}
}
// WebUIConfig returns configuration settings for the web UI.
func WebUIConfig(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/webui application WebUIConfiguration
//
// # Get web UI configuration
//
// Returns configuration settings for the web UI.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: WebUIConfigurationResponse
// default: ErrorResponse
conf := webUIConfiguration{}
conf.MessageRelay.Enabled = config.ReleaseEnabled
if config.ReleaseEnabled {
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
}
bytes, _ := json.Marshal(conf)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

View File

@@ -1,3 +1,4 @@
// Package server is the HTTP daemon
package server
import (
@@ -21,6 +22,9 @@ import (
//go:embed ui
var embeddedFS embed.FS
// AccessControlAllowOrigin CORS policy
var AccessControlAllowOrigin string
// Listen will start the httpd
func Listen() {
isReady := &atomic.Value{}
@@ -64,13 +68,12 @@ func Listen() {
isReady.Store(true)
if config.UITLSCert != "" && config.UITLSKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s%s", config.HTTPListen, config.Webroot)
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s%s", config.HTTPListen, config.Webroot)
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
func defaultRoutes() *mux.Router {
@@ -84,10 +87,15 @@ func defaultRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
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}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
return r
}
@@ -115,6 +123,12 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
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 config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -148,6 +162,12 @@ func middlewareHandler(h http.Handler) http.Handler {
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 config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()

View File

@@ -14,6 +14,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
@@ -150,7 +151,7 @@ func Test_APIv1(t *testing.T) {
}
func setup() {
config.NoLogging = true
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""

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

@@ -0,0 +1,139 @@
package smtpd
import (
"crypto/tls"
"errors"
"fmt"
"net/mail"
"net/smtp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
)
func allowedRecipients(to []string) []string {
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
return to
}
var ar []string
for _, recipient := range to {
address, err := mail.ParseAddress(recipient)
if err != nil {
logger.Log().Warnf("ignoring invalid email address: %s", recipient)
continue
}
if !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.RecipientAllowlist)
} else {
ar = append(ar, recipient)
}
}
return ar
}
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Send(from string, to []string, msg []byte) error {
recipients := allowedRecipients(to)
if len(recipients) == 0 {
return errors.New("no valid recipients")
}
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
}
defer c.Close()
if config.SMTPRelayConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host}
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
if a != nil {
if err = c.Auth(a); err != nil {
return fmt.Errorf("error authenticating: %s", err.Error())
}
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("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()
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
username, password string
}
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("Unknown fromServer")
}
}
return nil, nil
}

View File

@@ -3,6 +3,7 @@ package smtpd
import (
"bytes"
"fmt"
"net"
"net/mail"
"regexp"
@@ -12,46 +13,109 @@ import (
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/mhale/smtpd"
uuid "github.com/satori/go.uuid"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("error parsing message: %s", err.Error())
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
return err
}
if _, err := storage.Store(data); err != nil {
// Value with size 4800709 exceeded 1048576 limit
re := regexp.MustCompile(`(Value with size \d+ exceeded \d+ limit)`)
tooLarge := re.FindStringSubmatch(err.Error())
if len(tooLarge) > 0 {
logger.Log().Errorf("[db] error storing message: %s", tooLarge[0])
} else {
logger.Log().Errorf("[db] error storing message")
logger.Log().Errorf(err.Error())
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = uuid.NewV4().String() + "@mailpit"
// add unique ID
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
return nil
}
}
// if enabled, this will route the email 1:1 through to the preconfigured smtp server
if config.SMTPRelayAllIncoming {
if err := Send(from, to, data); err != nil {
logger.Log().Warnf("[smtp] error relaying message: %s", err.Error())
} else {
logger.Log().Debugf("[smtp] relayed message from %s via %s:%d", from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
}
}
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
missingAddresses := []string{}
for _, a := range to {
// loop through passed email addresses to check if they are in the headers
if _, err := mail.ParseAddress(a); err == nil {
_, ok := emails[strings.ToLower(a)]
if !ok {
missingAddresses = append(missingAddresses, a)
}
} else {
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
}
}
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
if hasBccHeader {
// email already has Bcc header, add to existing addresses
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurence
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
})
} else {
// prepend new Bcc header
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
data = append(bcc, data...)
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
_, err = storage.Store(data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtp] received (%s) from:%s to:%s subject:%q", cleanIP(origin), from, to[0], subject)
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
allow := config.SMTPAuth.Match(string(username), string(password))
allow := config.SMTPAuthConfig.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtp] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
logger.Log().Warnf("[smtp] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
}
return allow, nil
}
// Allow any username and password
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
logger.Log().Debugf("[smtp] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
return true, nil
}
@@ -59,19 +123,19 @@ func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, pass
func Listen() error {
if config.SMTPAuthAllowInsecure {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtp] enabling login auth via %s (insecure)", config.SMTPAuthFile)
logger.Log().Infof("[smtpd] enabling login auth via %s (insecure)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtp] enabling all auth (insecure)")
logger.Log().Info("[smtpd] enabling all auth (insecure)")
}
} else {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtp] enabling login auth via %s (TLS)", config.SMTPAuthFile)
logger.Log().Infof("[smtpd] enabling login auth via %s (TLS)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtp] enabling any auth (TLS)")
logger.Log().Info("[smtpd] enabling any auth (TLS)")
}
}
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
@@ -113,3 +177,33 @@ func cleanIP(i net.Addr) string {
return parts[0]
}
// Returns a list of all lowercased emails found in To, Cc and Bcc,
// as well as whether there is a Bcc field
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
emails := make(map[string]bool)
hasBccHeader := false
if recipients, err := h.AddressList("To"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
if recipients, err := h.AddressList("Cc"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
recipients, err := h.AddressList("Bcc")
if err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
hasBccHeader = true
}
return emails, hasBccHeader
}

View File

@@ -1,16 +1,20 @@
<script>
import commonMixins from './mixins.js';
import Message from './templates/Message.vue';
import MessageSummary from './templates/MessageSummary.vue';
import moment from 'moment';
import Tinycon from 'tinycon';
import commonMixins from './mixins.js'
import Message from './templates/Message.vue'
import MessageSummary from './templates/MessageSummary.vue'
import MessageRelease from './templates/MessageRelease.vue'
import MessageToast from './templates/MessageToast.vue'
import moment from 'moment'
import Tinycon from 'tinycon'
export default {
mixins: [commonMixins],
components: {
Message,
MessageSummary
MessageSummary,
MessageRelease,
MessageToast
},
data() {
@@ -36,7 +40,10 @@ export default {
selected: [],
tcStatus: 0,
appInfo: false,
lastLoaded: false
lastLoaded: false,
relayConfig: {},
releaseAddresses: false,
toastMessage: false,
}
},
@@ -67,6 +74,13 @@ export default {
},
canNext: function () {
return this.total > (this.start + this.count);
},
unreadInSearch: function () {
if (!this.searching) {
return false;
}
return this.items.filter(i => !i.Read).length;
}
},
@@ -76,7 +90,7 @@ export default {
this.currentPath = window.location.hash.slice(1);
});
this.notificationsSupported = 'https:' == document.location.protocol
this.notificationsSupported = window.isSecureContext
&& ("Notification" in window && Notification.permission !== "denied");
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
@@ -86,7 +100,29 @@ export default {
fallback: false
});
moment.updateLocale('en', {
relativeTime: {
future: "in %s",
past: "%s ago",
s: 'seconds',
ss: '%d secs',
m: "a minute",
mm: "%d mins",
h: "an hour",
hh: "%d hours",
d: "a day",
dd: "%d days",
w: "a week",
ww: "%d weeks",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
}
});
this.connect();
this.getUISettings();
this.loadMessages();
},
@@ -147,6 +183,13 @@ export default {
});
},
getUISettings: function () {
let self = this;
self.get('api/v1/webui', null, function (response) {
self.relayConfig = response.data;
});
},
doSearch: function (e) {
e.preventDefault();
this.loadMessages();
@@ -192,11 +235,12 @@ export default {
openMessage: function (id) {
let self = this;
self.selected = [];
self.releaseAddresses = false;
self.toastMessage = false;
self.existingTags = JSON.parse(JSON.stringify(self.tags));
let uri = 'api/v1/message/' + self.currentPath
self.get(uri, false, function (response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
if (!self.items[i].Read) {
@@ -205,51 +249,56 @@ export default {
}
}
}
let d = response.data;
// replace inline images
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
for (let i in d.Inline) {
let a = d.Inline[i];
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'),
window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
);
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'),
'src="' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
);
}
}
}
// replace inline images
// replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) {
for (let i in d.Attachments) {
let a = d.Attachments[i];
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'),
window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
);
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'),
'src="' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
);
}
}
}
self.message = d;
// generate the prev/next links based on current message list
self.messagePrev = false;
self.messageNext = false;
let found = false;
for (let i in self.items) {
if (self.items[i].ID == self.message.ID) {
found = true;
@@ -283,6 +332,24 @@ export default {
});
},
// delete messages displayed in current search
deleteSearch: function () {
let ids = this.items.map(item => item.ID);
if (!ids.length) {
return false;
}
let self = this;
let uri = 'api/v1/messages';
self.delete(uri, { 'ids': ids }, function (response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// delete all messages from mailbox
deleteAll: function () {
let self = this;
let uri = 'api/v1/messages';
@@ -292,6 +359,7 @@ export default {
});
},
// mark current message as read
markUnread: function () {
let self = this;
if (!self.message) {
@@ -305,6 +373,7 @@ export default {
});
},
// mark all messages in mailbox as read
markAllRead: function () {
let self = this;
let uri = 'api/v1/messages'
@@ -315,6 +384,24 @@ export default {
});
},
// mark messages in current search as read
markSearchRead: function () {
let ids = this.items.map(item => item.ID);
if (!ids.length) {
return false;
}
let self = this;
let uri = 'api/v1/messages';
self.put(uri, { 'read': true, 'ids': ids }, function (response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// mark selected messages as read
markSelectedRead: function () {
let self = this;
if (!self.selected.length) {
@@ -328,6 +415,7 @@ export default {
});
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this;
if (!self.selected.length) {
@@ -341,7 +429,7 @@ export default {
});
},
// test of any selected emails are unread
// test if any selected emails are unread
selectedHasUnread: function () {
if (!this.selected.length) {
return false;
@@ -383,10 +471,16 @@ export default {
if (response.Type == "new" && response.Data) {
if (!self.searching) {
if (self.start < 1) {
// first page
self.items.unshift(response.Data);
if (self.items.length > self.limit) {
self.items.pop();
}
// first message was open, set messagePrev
if (!self.messagePrev) {
self.messagePrev = response.Data.ID;
}
} else {
self.start++;
}
@@ -403,6 +497,7 @@ export default {
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
self.browserNotify("New mail from: " + from, response.Data.Subject);
self.setMessageToast(response.Data);
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
@@ -550,6 +645,47 @@ export default {
dl.target = '_blank';
dl.download = this.message.ID + '.' + ext;
dl.click();
},
initReleaseModal: function () {
this.releaseAddresses = false;
let addresses = [];
for (let i in this.message.To) {
addresses.push(this.message.To[i].Address)
}
for (let i in this.message.Cc) {
addresses.push(this.message.Cc[i].Address)
}
for (let i in this.message.Bcc) {
addresses.push(this.message.Bcc[i].Address)
}
// include only unique email addresses, regardless of casing
let uAddresses = new Map(addresses.map(a => [a.toLowerCase(), a]));
this.releaseAddresses = [...uAddresses.values()];
let self = this;
window.setTimeout(function () {
// delay to allow elements to load
self.modal('ReleaseModal').show();
window.setTimeout(function () {
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500);
}, 300);
},
setMessageToast: function (m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (this.notificationsEnabled || this.toastMessage) {
return;
}
this.toastMessage = m;
},
clearMessageToast: function () {
this.toastMessage = false;
}
}
}
@@ -572,6 +708,10 @@ export default {
<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
</button>
<button class="btn btn-outline-light me-2" title="Release message"
v-if="relayConfig.MessageRelay && relayConfig.MessageRelay.Enabled" v-on:click="initReleaseModal">
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
</button>
<button class="btn btn-outline-light me-2" title="Delete message" v-on:click="deleteMessages">
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button>
@@ -636,7 +776,8 @@ export default {
<span v-if="!total" class="ms-2">Mailpit</span>
</a>
<div v-if="total" class="ms-md-2 d-flex bg-white border rounded-start flex-fill position-relative">
<input type="text" class="form-control border-0" v-model.trim="search" placeholder="Search mailbox">
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
placeholder="Search mailbox">
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search"
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
</div>
@@ -647,13 +788,22 @@ export default {
</form>
</div>
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total">
<button v-if="total" class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" title="Delete all messages">
<button v-if="searching && items.length" class="btn btn-danger float-start d-md-none me-2"
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items" title="Delete results">
<i class="bi bi-trash-fill"></i>
</button>
<button v-else class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!total || searching" title="Delete all messages">
<i class="bi bi-trash-fill"></i>
</button>
<button v-if="unread" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" title="Mark all read">
<button v-if="searching && items.length" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch"
:title="'Mark ' + formatNumber(unreadInSearch) + ' read'">
<i class="bi bi-check2-square"></i>
</button>
<button v-else class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
<i class="bi bi-check2-square"></i>
</button>
@@ -693,10 +843,10 @@ export default {
class="list-group-item list-group-item-action" :class="!searching && !message ? 'active' : ''">
<template v-if="isConnected">
<i class="bi bi-envelope-fill me-1" v-if="!searching && !message"></i>
<i class="bi bi-arrow-return-left" v-else></i>
<i class="bi bi-arrow-return-left me-1" v-else></i>
</template>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
<span v-if="message" class="ms-1">Return</span>
<span v-if="message" class="ms-1 me-1">Return</span>
<span v-else class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages">
{{ formatNumber(unread) }}
@@ -704,38 +854,49 @@ export default {
</a>
<template v-if="!message && !selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
<button v-if="searching && items.length" class="list-group-item list-group-item-action"
data-bs-toggle="modal" data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch">
<i class="bi bi-eye-fill me-1"></i>
Mark {{ formatNumber(unreadInSearch) }} read
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
<i class="bi bi-eye-fill"></i>
<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="searching && items.length" class="list-group-item list-group-item-action"
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete {{ formatNumber(items.length) }} message<span v-if="items.length > 1">s</span>
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!total || searching">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal"
v-if="isConnected && notificationsSupported && !notificationsEnabled">
<i class="bi bi-bell"></i>
<i class="bi bi-bell me-1"></i>
Enable alerts
</button>
</template>
<template v-if="!message && selected.length">
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
v-on:click="markSelectedRead">
<i class="bi bi-eye-fill"></i>
<i class="bi bi-eye-fill me-1"></i>
Mark read
</button>
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
v-on:click="markSelectedUnread">
<i class="bi bi-eye-slash"></i>
<i class="bi bi-eye-slash me-1"></i>
Mark unread
</button>
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
Delete selected
</button>
<button class="list-group-item list-group-item-action" v-on:click="selected = []">
<i class="bi bi-x-circle me-1"></i>
@@ -745,9 +906,23 @@ export default {
</div>
<template v-if="!selected.length && tags.length && !message">
<h6 class="mt-4 text-muted"><small>Tags</small></h6>
<div class="list-group mt-2 mb-5">
<button class="list-group-item list-group-item-action" v-for="tag in tags"
<div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @click="toggleTagColors()">
<template v-if="showTagColors">Hide</template>
<template v-else>Show</template>
tag colors
</button>
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5">
<button class="list-group-item list-group-item-action small px-2" v-for="tag in tags"
:style="showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
v-on:click="tagSearch($event, tag)" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
<i class="bi bi-tag" v-else></i>
@@ -758,7 +933,7 @@ export default {
<MessageSummary v-if="message" :message="message"></MessageSummary>
<div class="position-fixed bottom-0 bg-white py-2 text-muted w-100">
<div class="position-fixed bottom-0 bg-white py-2 text-muted small w-100">
<a href="#" class="text-muted" v-on:click="loadInfo">
<i class="bi bi-info-circle-fill"></i>
About
@@ -766,13 +941,13 @@ export default {
</div>
</div>
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="col-lg-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none' : ''" id="message-page">
<div class="list-group my-2" v-if="items.length">
<a v-for="message in items" :href="'#' + message.ID"
<a v-for="message in items" :href="'#' + message.ID" :key="message.ID"
v-on:click.ctrl="toggleSelected($event, message.ID)"
v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
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' : ''">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
@@ -798,18 +973,21 @@ export default {
</span>
</div>
</div>
<div class="col-lg-6 mt-2 mt-lg-0">
<span class="badge text-bg-secondary me-1" v-for="t in message.Tags"
:title="'Filter messages tagged with ' + t" v-on:click="tagSearch($event, t)">
{{ t }}
</span>
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
<div><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
<div>
<span class="badge me-1" v-for="t in message.Tags"
:style="showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t" v-on:click="tagSearch($event, t)">
{{ t }}
</span>
</div>
</div>
<div class="d-none d-lg-block col-1 small text-end text-muted">
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
{{ getFileSize(message.Size) }}
</div>
<div class="d-none d-lg-block col-2 small text-end text-muted">
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
</a>
@@ -856,6 +1034,29 @@ export default {
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="DeleteSearchModal" tabindex="-1" aria-labelledby="DeleteSearchModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteSearchModalLabel">
Delete {{ formatNumber(items.length) }} search result<span v-if="items.length > 1">s</span>?
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(items.length) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
v-on:click="deleteSearch">Delete</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -876,6 +1077,30 @@ export default {
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="MarkSearchReadModal" tabindex="-1" aria-labelledby="MarkSearchReadModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkSearchReadModalLabel">
Mark {{ formatNumber(unreadInSearch) }} search result<span v-if="unreadInSearch > 1">s</span> as
read?
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(unreadInSearch) }} message<span v-if="unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markSearchRead">Confirm</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true">
@@ -963,4 +1188,12 @@ export default {
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<MessageRelease v-if="releaseAddresses" :message="message" :relayConfig="relayConfig"
:releaseAddresses="releaseAddresses"></MessageRelease>
</div>
<MessageToast v-if="toastMessage" :message="toastMessage" @clearMessageToast="clearMessageToast"></MessageToast>
</template>

View File

@@ -1,3 +1,8 @@
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans",
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;

View File

@@ -4,6 +4,7 @@
// Configuration
@import "../../../node_modules/bootstrap/scss/functions";
@import "../../../node_modules/bootstrap/scss/variables";
@import "../../../node_modules/bootstrap/scss/variables-dark";
@import "../../../node_modules/bootstrap/scss/maps";
@import "../../../node_modules/bootstrap/scss/mixins";
@import "../../../node_modules/bootstrap/scss/utilities";
@@ -32,7 +33,7 @@
// @import "../../../node_modules/bootstrap/scss/progress";
@import "../../../node_modules/bootstrap/scss/list-group";
@import "../../../node_modules/bootstrap/scss/close";
// @import "../../../node_modules/bootstrap/scss/toasts";
@import "../../../node_modules/bootstrap/scss/toasts";
@import "../../../node_modules/bootstrap/scss/modal";
// @import "../../../node_modules/bootstrap/scss/tooltip";
// @import "../../../node_modules/bootstrap/scss/popover";

View File

@@ -91,6 +91,89 @@
#preview-html {
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
margin: auto;
transition: width 0.5s;
position: relative;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
iframe {
height: 100% !important;
background: #fff;
}
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
}
.list-group-item.message:first-child {
@@ -168,6 +251,10 @@ body.blur {
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
}
#DownloadBtn {
@@ -181,6 +268,14 @@ body.blur {
}
}
#ReleaseModal {
.form-control.dropdown {
div {
@extend .form-control;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
@@ -302,8 +397,8 @@ pre[class*="language-"] {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]:after,
pre[class*="language-"]:before {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}

View File

@@ -1,22 +1,32 @@
import axios from 'axios';
import { Modal } from 'bootstrap';
import moment from 'moment';
import axios from 'axios'
import { Modal } from 'bootstrap'
import moment from 'moment'
import ColorHash from 'color-hash'
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing
// if the ID returns nothing to prevent errors.
function FakeModal() { }
FakeModal.prototype.hide = function () { alert('close fake modal') }
FakeModal.prototype.show = function () { alert('open fake modal') }
FakeModal.prototype.hide = function () { }
FakeModal.prototype.show = function () { }
// Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0
loading: 0,
tagColorCache: {},
showTagColors: true
}
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -201,6 +211,27 @@ const commonMixins = {
}
return 'bi-file-arrow-down-fill';
},
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}
this.tagColorCache[s] = colorHash.hex(s)
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
}
}

View File

@@ -26,7 +26,15 @@ export default {
showTags: false, // to force rerendering of component
messageTags: [],
allTags: [],
loadHeaders: false
loadHeaders: false,
showMobileBtns: false,
scaleHTMLPreview: 'display',
// keys names match bootstrap icon names
responsiveSizes: {
phone: 'width: 322px; height: 570px',
tablet: 'width: 768px; height: 1024px',
display: 'width: 100%; height: 100%',
},
}
},
@@ -38,6 +46,7 @@ export default {
self.messageTags = self.message.Tags;
self.allTags = self.existingTags;
self.loadHeaders = false;
self.scaleHTMLPreview = 'display';// default view
// delay to select first tab and add HTML highlighting (prev/next)
self.$nextTick(function () {
self.renderUI();
@@ -55,6 +64,14 @@ export default {
if (this.showTags) {
this.saveTags();
}
},
scaleHTMLPreview() {
if (this.scaleHTMLPreview == 'display') {
let self = this;
window.setTimeout(function () {
self.resizeIframes();
}, 500);
}
}
},
@@ -124,15 +141,14 @@ export default {
},
resizeIframes: function () {
if (this.scaleHTMLPreview != 'display') {
return;
}
let h = document.getElementById('preview-html');
if (h) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
}
let s = document.getElementById('message-src');
if (s) {
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
}
},
saveTags: function () {
@@ -147,6 +163,32 @@ export default {
self.scrollInPlace = true;
self.$emit('loadMessages');
});
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
let html = s
// full links with http(s)
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+.~#?,&\/\/=;]+)\b/gim
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
// plain www links without https?:// prefix
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
// escape to HTML & convert <>" back
html = html
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/˱˱˱/g, '<')
.replace(/˲˲˲/g, '>')
.replace(/ˠˠˠ/g, '"')
return html
}
}
}
@@ -179,7 +221,7 @@ export default {
<template v-if="i > 0">, </template>
<span class="text-nowrap">{{ t.Name + " &lt;" + t.Address + "&gt;" }}</span>
</span>
<span v-else>Undisclosed recipients</span>
<span v-else class="text-muted">[Undisclosed recipients]</span>
</td>
</tr>
<tr v-if="message.Cc && message.Cc.length" class="small">
@@ -195,7 +237,8 @@ export default {
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
{{ t.Name + " &lt;" + t.Address + "&gt;" }}
</span>
</td>
</tr>
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
@@ -206,9 +249,16 @@ export default {
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr v-if="message.ReturnPath && message.ReturnPath != message.From.Address" class="small">
<th class="text-nowrap">Return-Path</th>
<td class="privacy text-muted">&lt;{{ message.ReturnPath }}&gt;</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
<td>
<strong v-if="message.Subject != ''">{{ message.Subject }}</strong>
<small class="text-muted" v-else>[ no subject ]</small>
</td>
</tr>
<tr class="d-md-none small">
<th class="small">Date</th>
@@ -219,9 +269,10 @@ export default {
<th>Tags</th>
<td>
<select class="form-select small tag-selector" v-model="messageTags" multiple
data-allow-new="true" data-clear-end="true" data-allow-clear="true"
data-placeholder="Add tags..." data-badge-style="secondary"
data-regex="^([a-zA-Z0-9\-\ \_]){3,}$" data-separator="|,|">
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_]){3,}$"
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allTags" :value="t">{{ t }}</option>
@@ -245,29 +296,45 @@ export default {
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button"
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML">HTML</button>
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML"
v-on:click="showMobileBtns = true; resizeIframes()">HTML</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source"
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML">
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML"
v-on:click=" showMobileBtns = false">
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
:class="message.HTML == '' ? 'show' : ''">Text</button>
:class="message.HTML == '' ? 'show' : ''" v-on:click=" showMobileBtns = false">Text</button>
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
type="button" role="tab" aria-controls="nav-headers" aria-selected="false"
v-on:click=" showMobileBtns = false">
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
role="tab" aria-controls="nav-raw" aria-selected="false">Raw</button>
role="tab" aria-controls="nav-raw" aria-selected="false"
v-on:click=" showMobileBtns = false">Raw</button>
<div class="d-none d-lg-block ms-auto me-2" v-if="showMobileBtns">
<template v-for=" vals, key in responsiveSizes ">
<button class="btn" :class="scaleHTMLPreview == key ? 'btn-outline-primary' : ''"
:disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
v-on:click=" scaleHTMLPreview = key">
<i class="bi" :class="'bi-' + key"></i>
</button>
</template>
</div>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="message.HTML"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%;">
</iframe>
</div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
@@ -277,7 +344,7 @@ export default {
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class="message.HTML == '' ? 'show' : ''">
<div class="text-view">{{ message.Text }}</div>
<div class="text-view" v-html="textToHTML(message.Text)"></div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
@@ -285,8 +352,8 @@ export default {
<Headers v-if="loadHeaders" :message="message"></Headers>
</div>
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe" seamless frameborder="0"
style="width: 100%; height: 300px;" id="message-src"></iframe>
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe" frameborder="0"
style="width: 100%; height: 300px; background: #fff; color: #15141A"></iframe>
</div>
</div>
</div>

View File

@@ -0,0 +1,114 @@
<script>
import Tags from "bootstrap5-tags";
import commonMixins from '../mixins.js';
export default {
props: {
message: Object,
relayConfig: Object,
releaseAddresses: Array
},
data() {
return {
addresses: []
}
},
mixins: [commonMixins],
mounted() {
this.addresses = JSON.parse(JSON.stringify(this.releaseAddresses));
this.$nextTick(function () {
Tags.init("select[multiple]");
});
},
methods: {
releaseMessage: function () {
let self = this;
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false;
}
let data = {
to: self.addresses
}
self.post('api/v1/message/' + self.message.ID + '/release', data, function (response) {
self.modal("ReleaseModal").hide();
});
}, 100);
}
}
}
</script>
<template>
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-muted">From</label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" :value="message.From.Address">
</div>
</div>
<div class="row">
<label class=" col-sm-2 col-form-label text-muted">Subject</label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" :value="message.Subject">
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 col-form-label text-muted">Send to</label>
<div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Enter email addresses..."
data-add-on-blur="true" data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|">
<option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in releaseAddresses" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div class="form-text text-center" v-if="relayConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ relayConfig.MessageRelay.RecipientAllowlist }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="relayConfig.MessageRelay.ReturnPath != ''">{{ relayConfig.MessageRelay.ReturnPath }}</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script>
import { Toast } from 'bootstrap';
export default {
props: {
message: Object
},
mounted() {
let self = this;
let el = document.getElementById('messageToast');
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.$emit("clearMessageToast");
})
let b = Toast.getOrCreateInstance(el);
b.show();
}
}
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto"><a :href="'#' + message.ID">New message</a></strong>
<small class="text-body-secondary">now</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<a :href="'#' + message.ID" class="d-block text-truncate text-muted">
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
<template v-else>[ no subject ]</template>
</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -16,8 +16,8 @@
regular-font="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'"
mono-font="Courier New, Courier, System, fixed-width" font-size="large" allow-spec-url-load="false"
allow-spec-file-load="false" allow-server-selection="false" allow-search="false" allow-advanced-search="false"
bg-color="#ffffff" nav-bg-color="#e3e8ec" nav-text-color="#212529" nav-hover-bg-color="#fff"
header-color="#2c3e50" primary-color="#2c3e50" text-color="#212529">
show-curl-before-try="true" bg-color="#ffffff" nav-bg-color="#e3e8ec" nav-text-color="#212529"
nav-hover-bg-color="#fff" header-color="#2c3e50" primary-color="#2c3e50" text-color="#212529">
<div slot="header">Mailpit API v1 documentation</div>
<a target='_blank' href="../../" slot="logo">
<img src="../../mailpit.svg" width="40" alt="Mailpit" />

View File

@@ -27,7 +27,7 @@
"get": {
"description": "Returns basic runtime information, message totals and latest release version.",
"produces": [
"application/octet-stream"
"application/json"
],
"schemes": [
"http",
@@ -36,7 +36,7 @@
"tags": [
"application"
],
"summary": "Get the application information",
"summary": "Get application information",
"operationId": "AppInformation",
"responses": {
"200": {
@@ -66,7 +66,7 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -103,7 +103,7 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -142,14 +142,14 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "attachment part id",
"description": "Attachment part ID",
"name": "PartID",
"in": "path",
"required": true
@@ -183,14 +183,14 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "attachment part id",
"description": "Attachment part ID",
"name": "PartID",
"in": "path",
"required": true
@@ -224,7 +224,7 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -240,6 +240,54 @@
}
}
},
"/api/v1/message/{ID}/release": {
"post": {
"description": "Release a message via a pre-configured external SMTP server..",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Release message",
"operationId": "Release",
"parameters": [
{
"type": "string",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
},
{
"description": "Array of email addresses to release message to",
"name": "to",
"in": "body",
"required": true,
"schema": {
"description": "Array of email addresses to release message to",
"type": "object",
"$ref": "#/definitions/ReleaseMessageRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/messages": {
"get": {
"description": "Returns messages from the mailbox ordered from newest to oldest.",
@@ -259,14 +307,14 @@
{
"type": "integer",
"default": 0,
"description": "pagination offset",
"description": "Pagination offset",
"name": "start",
"in": "query"
},
{
"type": "integer",
"default": 50,
"description": "limit results",
"description": "Limit results",
"name": "limit",
"in": "query"
}
@@ -299,11 +347,11 @@
"operationId": "SetReadStatus",
"parameters": [
{
"description": "Message ids to update",
"description": "Database IDs to update",
"name": "ids",
"in": "body",
"schema": {
"description": "Message ids to update",
"description": "Database IDs to update",
"type": "object",
"$ref": "#/definitions/SetReadStatusRequest"
}
@@ -337,11 +385,11 @@
"operationId": "Delete",
"parameters": [
{
"description": "Message ids to delete",
"description": "Database IDs to delete",
"name": "ids",
"in": "body",
"schema": {
"description": "Message ids to delete",
"description": "Database IDs to delete",
"type": "object",
"$ref": "#/definitions/DeleteRequest"
}
@@ -375,7 +423,7 @@
"parameters": [
{
"type": "string",
"description": "search query",
"description": "Search query",
"name": "query",
"in": "query",
"required": true
@@ -383,7 +431,7 @@
{
"type": "integer",
"default": 50,
"description": "limit results",
"description": "Limit results",
"name": "limit",
"in": "query"
}
@@ -418,12 +466,12 @@
"operationId": "SetTags",
"parameters": [
{
"description": "Message ids to update",
"description": "Database IDs to update",
"name": "ids",
"in": "body",
"required": true,
"schema": {
"description": "Message ids to update",
"description": "Database IDs to update",
"type": "object",
"$ref": "#/definitions/SetTagsRequest"
}
@@ -438,6 +486,31 @@
}
}
}
},
"/api/v1/webui": {
"get": {
"description": "Returns configuration settings for the web UI.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"application"
],
"summary": "Get web UI configuration",
"operationId": "WebUIConfiguration",
"responses": {
"200": {
"$ref": "#/responses/WebUIConfigurationResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
}
},
"definitions": {
@@ -456,7 +529,7 @@
"x-go-package": "net/mail"
},
"AppInformation": {
"description": "Response includes the current and latest Mailpit versions, database info, and memory usage",
"description": "Response includes the current and latest Mailpit version, database info, and memory usage",
"type": "object",
"properties": {
"Database": {
@@ -495,23 +568,23 @@
"type": "object",
"properties": {
"ContentID": {
"description": "content id",
"description": "Content ID",
"type": "string"
},
"ContentType": {
"description": "content type",
"description": "Content type",
"type": "string"
},
"FileName": {
"description": "file name",
"description": "File name",
"type": "string"
},
"PartID": {
"description": "attachment part id",
"description": "Attachment part ID",
"type": "string"
},
"Size": {
"description": "size in bytes",
"description": "Size in bytes",
"type": "integer",
"format": "int64"
}
@@ -572,7 +645,7 @@
"type": "string"
},
"ID": {
"description": "Unique message database id",
"description": "Database ID",
"type": "string"
},
"Inline": {
@@ -582,6 +655,10 @@
"$ref": "#/definitions/Attachment"
}
},
"MessageID": {
"description": "Message ID",
"type": "string"
},
"Read": {
"description": "Read status",
"type": "boolean"
@@ -593,6 +670,10 @@
"$ref": "#/definitions/Address"
}
},
"ReturnPath": {
"description": "Return-Path",
"type": "string"
},
"Size": {
"description": "Message size in bytes",
"type": "integer",
@@ -667,7 +748,11 @@
"$ref": "#/definitions/Address"
},
"ID": {
"description": "Unique message database id",
"description": "Database ID",
"type": "string"
},
"MessageID": {
"description": "Message ID",
"type": "string"
},
"Read": {
@@ -747,6 +832,22 @@
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"ReleaseMessageRequest": {
"description": "Release request",
"type": "object",
"properties": {
"to": {
"description": "To\nin:body",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "To"
}
},
"x-go-name": "releaseMessageRequest",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"SetReadStatusRequest": {
"description": "Set read status request",
"type": "object",
@@ -773,7 +874,7 @@
"type": "object",
"properties": {
"ids": {
"description": "ids\nin:body",
"description": "IDs\nin:body",
"type": "array",
"items": {
"type": "string"
@@ -791,6 +892,36 @@
},
"x-go-name": "setTagsRequest",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"WebUIConfiguration": {
"description": "Response includes global web UI settings",
"type": "object",
"properties": {
"MessageRelay": {
"description": "Message Relay information",
"type": "object",
"properties": {
"Enabled": {
"description": "Whether message relaying (release) is enabled",
"type": "boolean"
},
"RecipientAllowlist": {
"description": "Allowlist of accepted recipients",
"type": "string"
},
"ReturnPath": {
"description": "Enforced Return-Path (if set) for relay bounces",
"type": "string"
},
"SMTPServer": {
"description": "The configured SMTP server address",
"type": "string"
}
}
}
},
"x-go-name": "webUIConfiguration",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
}
},
"responses": {
@@ -822,6 +953,17 @@
},
"TextResponse": {
"description": "Plain text response"
},
"WebUIConfigurationResponse": {
"description": "Web UI configuration",
"schema": {
"$ref": "#/definitions/WebUIConfiguration"
},
"headers": {
"Body": {
"description": "Web UI configuration settings"
}
}
}
}
}

View File

@@ -72,20 +72,51 @@ var (
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.2,
Description: "Creating new mailbox format",
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO mailboxtmp
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM mailbox;
DROP TABLE IF EXISTS mailbox;
ALTER TABLE mailboxtmp RENAME TO mailbox;
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
}
)
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
Created time.Time
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Size int
Inline int
Attachments int
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
}
// InitDB will initialise the database
@@ -144,6 +175,8 @@ func InitDB() error {
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
@@ -174,7 +207,7 @@ func Close() {
// Store will save an email to the database tables
func Store(body []byte) (string, error) {
// Parse message body with enmime.
// Parse message body with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
if err != nil {
logger.Log().Warningf("[db] %s", err.Error())
@@ -189,22 +222,21 @@ func Store(body []byte) (string, error) {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
obj := DBMailSummary{
Created: time.Now(),
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
Subject: env.GetHeader("Subject"),
Size: len(body),
Inline: len(env.Inlines),
Attachments: len(env.Attachments),
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
}
created := time.Now()
// use message date instead of created date
if config.UseMessageDates {
if mDate, err := env.Date(); err == nil {
obj.Created = mDate
created = mDate
}
}
@@ -219,7 +251,16 @@ func Store(body []byte) (string, error) {
return "", err
}
tagData := findTags(&body)
// extract tags from body matches based on --tag
tagStr := findTagsInRawMessage(&body)
// extract tags from X-Tags header
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
if headerTags != "" {
tagStr += "," + headerTags
}
tagData := uniqueTagsFromString(tagStr)
tagJSON, err := json.Marshal(tagData)
if err != nil {
@@ -237,8 +278,14 @@ func Store(body []byte) (string, error) {
// roll back if it fails
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := len(body)
inline := len(env.Inlines)
attachments := len(env.Attachments)
// insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Tags, Read) values(?,?,?,?,0)", id, string(summaryJSON), searchText, string(tagJSON))
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read) values(?,?,?,?,?,?,?,?,?,?,0)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON))
if err != nil {
return "", err
}
@@ -259,9 +306,13 @@ func Store(body []byte) (string, error) {
return "", err
}
c.Tags = tagData
c.Created = created
c.ID = id
c.MessageID = messageID
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
websockets.Broadcast("new", c)
@@ -276,24 +327,29 @@ func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
q := sqlf.From("mailbox").
Select(`ID, Data, Tags, Read`).
OrderBy("Sort DESC").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
OrderBy("Created DESC").
Limit(limit).
Offset(start)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var summary string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
em := MessageSummary{}
if err := row.Scan(&id, &summary, &tags, &read); err != nil {
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(summary), &em); err != nil {
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
@@ -303,11 +359,18 @@ func List(start, limit int) ([]MessageSummary, error) {
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
results = append(results, em)
// logger.PrettyPrint(em)
}); err != nil {
return results, err
}
@@ -318,7 +381,7 @@ func List(start, limit int) ([]MessageSummary, error) {
}
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, error) {
@@ -342,19 +405,24 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
q := searchParser(args, start, limit)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var summary string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&id, &summary, &tags, &read, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(summary), &em); err != nil {
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
@@ -364,7 +432,12 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
results = append(results, em)
@@ -404,49 +477,51 @@ func GetMessage(id string) (*Message, error) {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From("mailbox").
Select(`Data`).
OrderBy("Sort DESC").
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var summary string
em := MessageSummary{}
var created int64
if err := row.Scan(&summary); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(summary), &em); err != nil {
if err := row.Scan(&created); err != nil {
logger.Log().Error(err)
return
}
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = em.Created
date = time.UnixMilli(created)
}); err != nil {
logger.Log().Error(err)
}
}
obj := Message{
ID: id,
Read: true,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: len(raw),
Text: env.Text,
ID: id,
MessageID: messageID,
Read: true,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: len(raw),
Text: env.Text,
}
// strip base tags
@@ -815,3 +890,16 @@ func IsUnread(id string) bool {
return unread == 1
}
// MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("MessageID = ?", id)
_ = q.QueryRowAndClose(nil, db)
return total != 0
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
@@ -230,7 +231,7 @@ func BenchmarkImportMime(b *testing.B) {
}
func setup() {
config.NoLogging = true
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""

200
storage/migrationTasks.go Normal file
View File

@@ -0,0 +1,200 @@
package storage
import (
"bytes"
"context"
"database/sql"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func dataMigrations() {
updateOrderByCreatedTask()
assignMessageIDsTask()
}
// Update Created column using Created metadata datetime <= v1.6.5
// Migration task implemented 05/2023 - can be removed end 2023
func updateOrderByCreatedTask() {
q := sqlf.From("mailbox").
Select("ID").
Select(`json_extract(Metadata, '$.Created') as Created`).
Where("Created < ?", 1155000600)
toUpdate := make(map[string]int64)
p := message.NewPrinter(language.English)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
var ts sql.NullString
if err := row.Scan(&id, &ts); err != nil {
logger.Log().Error("[migration]", err)
return
}
if !ts.Valid {
logger.Log().Errorf("[migration] cannot get Created timestamp from %s", id)
return
}
t, _ := time.Parse(time.RFC3339Nano, ts.String)
toUpdate[id] = t.UnixMilli()
}); err != nil {
logger.Log().Error("[migration]", err)
return
}
total := len(toUpdate)
if total == 0 {
return
}
logger.Log().Infof("[migration] updating timestamp for %s messages", p.Sprintf("%d", len(toUpdate)))
// begin a transaction
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Error("[migration]", err)
return
}
// roll back if it fails
defer tx.Rollback()
var blockTime = time.Now()
count := 0
for id, ts := range toUpdate {
count++
_, err := tx.Exec(`UPDATE mailbox SET Created = ? WHERE ID = ?`, ts, id)
if err != nil {
logger.Log().Error("[migration]", err)
}
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] updated timestamp for 1,000 messages [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
if err := tx.Commit(); err != nil {
logger.Log().Error("[migration]", err)
return
}
logger.Log().Infof("[migration] complete")
}
// Find any messages without a stored Message-ID and update it <= v1.6.5
// Migration task implemented 05/2023 - can be removed end 2023
func assignMessageIDsTask() {
if !config.IgnoreDuplicateIDs {
return
}
q := sqlf.From("mailbox").
Select("ID").
Where("MessageID = ''")
missingIDS := make(map[string]string)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id); err != nil {
logger.Log().Error("[migration]", err)
return
}
missingIDS[id] = ""
}); err != nil {
logger.Log().Error("[migration]", err)
}
if len(missingIDS) == 0 {
return
}
var count int
var blockTime = time.Now()
p := message.NewPrinter(language.English)
total := len(missingIDS)
logger.Log().Infof("[migration] extracting Message-IDs for %s messages", p.Sprintf("%d", total))
for id := range missingIDS {
raw, err := GetMessageRaw(id)
if err != nil {
logger.Log().Error("[migration]", err)
continue
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
logger.Log().Error("[migration]", err)
continue
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
missingIDS[id] = messageID
count++
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] extracted 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
// begin a transaction
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Error("[migration]", err)
return
}
// roll back if it fails
defer tx.Rollback()
count = 0
for id, mid := range missingIDS {
_, err = tx.Exec(`UPDATE mailbox SET MessageID = ? WHERE ID = ?`, mid, id)
if err != nil {
logger.Log().Error("[migration]", err)
}
count++
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] stored 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
if err := tx.Commit(); err != nil {
logger.Log().Error("[migration]", err)
return
}
logger.Log().Infof("[migration] complete")
}

View File

@@ -14,15 +14,13 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
}
q := sqlf.From("mailbox").
Select(`ID, Data, Tags, Read,
json_extract(Data, '$.To') as ToJSON,
json_extract(Data, '$.From') as FromJSON,
IFNULL(json_extract(Data, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Data, '$.Bcc'), '{}') as BccJSON,
json_extract(Data, '$.Subject') as Subject,
json_extract(Data, '$.Attachments') as Attachments
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON
`).
OrderBy("Sort DESC").
OrderBy("Created DESC").
Limit(limit).
Offset(start)
@@ -92,6 +90,15 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "message-id:") {
w = cleanString(w[11:])
if w != "" {
if exclude {
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "tag:") {
w = cleanString(w[4:])
if w != "" {
@@ -122,9 +129,9 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
} else {
// search text
if exclude {
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}

View File

@@ -11,8 +11,10 @@ import (
//
// swagger:model Message
type Message struct {
// Unique message database id
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
@@ -25,6 +27,8 @@ type Message struct {
Bcc []*mail.Address
// ReplyTo addresses
ReplyTo []*mail.Address
// Return-Path
ReturnPath string
// Message subject
Subject string
// Message date if set, else date received
@@ -47,15 +51,15 @@ type Message struct {
//
// swagger:model Attachment
type Attachment struct {
// attachment part id
// Attachment part ID
PartID string
// file name
// File name
FileName string
// content type
// Content type
ContentType string
// content id
// Content ID
ContentID string
// size in bytes
// Size in bytes
Size int
}
@@ -63,8 +67,10 @@ type Attachment struct {
//
// swagger:model MessageSummary
type MessageSummary struct {
// Unique message database id
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address

View File

@@ -3,27 +3,27 @@ package storage
import (
"context"
"encoding/json"
"regexp"
"sort"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/leporo/sqlf"
)
// SetTags will set the tags for a given message ID, used via API
// SetTags will set the tags for a given database ID, used via API
func SetTags(id string, tags []string) error {
applyTags := []string{}
reg := regexp.MustCompile(`\s+`)
for _, t := range tags {
t = strings.TrimSpace(reg.ReplaceAllString(t, " "))
if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
sort.Strings(applyTags)
tagJSON, err := json.Marshal(applyTags)
if err != nil {
logger.Log().Errorf("[db] setting tags for message %s", id)
@@ -42,26 +42,25 @@ func SetTags(id string, tags []string) error {
return err
}
// Used to auto-apply tags to new messages
func findTags(message *[]byte) []string {
tags := []string{}
// Find tags set via --tags in raw message.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 {
return tags
return tagStr
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) {
tags = append(tags, t.Tag)
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
}
}
sort.Strings(tags)
return tags
return tagStr
}
// Get message tags from the database for a given message ID.
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
@@ -84,3 +83,31 @@ func getMessageTags(id string) []string {
return tags
}
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
func uniqueTagsFromString(s string) []string {
tags := []string{}
if s == "" {
return tags
}
parts := strings.Split(s, ",")
for _, p := range parts {
w := tools.CleanTag(p)
if w == "" {
continue
}
if config.ValidTagRegexp.MatchString(w) {
if !inArray(w, tags) {
tags = append(tags, w)
}
} else {
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
}
}
sort.Strings(tags)
return tags
}

View File

@@ -39,7 +39,7 @@ func createSearchText(env *enmime.Envelope) string {
b.WriteString(env.GetHeader("Bcc") + " ")
h := strings.TrimSpace(
html2text.HTML2TextWithOptions(
env.HTML,
env.HTML,
html2text.WithLinksInnerText(),
),
)
@@ -75,7 +75,7 @@ func dbCron() {
time.Sleep(60 * time.Second)
start := time.Now()
// check if database contains deleted data and has not beein in use
// check if database contains deleted data and has not been in use
// for 5 minutes, if so VACUUM
currentTime := time.Now()
diff := currentTime.Sub(dbLastAction)
@@ -92,7 +92,7 @@ func dbCron() {
if config.MaxMessages > 0 {
q := sqlf.Select("ID").
From("mailbox").
OrderBy("Sort DESC").
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
@@ -167,6 +167,7 @@ func isFile(path string) bool {
return true
}
// InArray tests if a string in within an array. It is not case sensitive.
func inArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {

View File

@@ -1,16 +1,23 @@
// Package logger handles the logging
package logger
import (
"encoding/json"
"fmt"
"os"
"regexp"
"github.com/axllent/mailpit/config"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
// VerboseLogging for verbose logging
VerboseLogging bool
// QuietLogging shows only errors
QuietLogging bool
// NoLogging shows only fatal errors
NoLogging bool
)
// Log returns the logger instance
@@ -18,13 +25,13 @@ func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
if VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if config.QuietLogging {
} else if QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if config.NoLogging {
} else if NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}
@@ -45,3 +52,25 @@ func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}
// CleanIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
func CleanIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "0.0.0.0:" + s[5:]
}
return s
}
// CleanHTTPIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "localhost:<port>"
func CleanHTTPIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "localhost:" + s[5:]
}
return s
}

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

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

25
utils/tools/tags.go Normal file
View File

@@ -0,0 +1,25 @@
package tools
import (
"regexp"
"strings"
)
var (
// Invalid tag characters regex
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
// Regex to catch multiple spaces
multiSpaceRe = regexp.MustCompile(`(\s+)`)
)
// CleanTag returns a clean tag, removing whitespace and invalid characters
func CleanTag(s string) string {
s = strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
return s
}