Compare commits

..

112 Commits

Author SHA1 Message Date
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
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
40 changed files with 2474 additions and 2686 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 }}

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

View File

@@ -2,6 +2,154 @@
Notable changes to Mailpit will be documented in this file.
## [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

@@ -12,11 +12,11 @@ var (
// sendmailCmd represents the sendmail command
var sendmailCmd = &cobra.Command{
Use: "sendmail",
Short: "A sendmail command replacement",
Long: `A sendmail command replacement.
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Long: `A sendmail command replacement for Mailpit.
You can optionally create a symlink called 'sendmail' to the main binary.`,
You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
@@ -26,8 +26,12 @@ 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().StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&smtpAddr, "smtp-addr", "S", smtpAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
}

View File

@@ -10,16 +10,18 @@ import (
"regexp"
"strings"
"github.com/axllent/mailpit/utils/logger"
"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 +32,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
@@ -63,8 +56,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,6 +65,9 @@ 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
@@ -79,7 +75,20 @@ var (
TagRegexp = 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 preconfgured 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 +103,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, 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"` // allows overriding the boune 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 +191,7 @@ func VerifyConfig() error {
if err != nil {
return err
}
SMTPAuth = a
SMTPAuthConfig = a
}
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
@@ -182,7 +206,7 @@ func VerifyConfig() error {
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
SMTPTags = []Tag{}
SMTPTags = []AutoTag{}
p := shellwords.NewParser()
@@ -203,11 +227,86 @@ func VerifyConfig() error {
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 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)
}

31
go.mod
View File

@@ -9,18 +9,19 @@ require (
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/k3a/html2text v1.2.1
github.com/klauspost/compress v1.16.5
github.com/leporo/sqlf v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
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.2
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.9.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.22.1
)
require (
@@ -35,7 +36,7 @@ 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
@@ -43,17 +44,17 @@ require (
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.9.0 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/tools v0.9.1 // 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/libc v1.22.6 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect

73
go.sum
View File

@@ -53,7 +53,6 @@ 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=
@@ -62,12 +61,12 @@ github.com/jhillyerd/enmime v0.11.1 h1:U6ToGVxfxNQQhKrAaGxtwOf7Zqksb8AQ3j1CyAWOk
github.com/jhillyerd/enmime v0.11.1/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.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/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=
@@ -105,21 +104,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.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
github.com/sirupsen/logrus v1.9.2/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 +131,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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
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.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
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.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.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.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
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 +158,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.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.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 +168,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.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
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=
@@ -189,19 +194,19 @@ 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/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.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
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/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.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
modernc.org/sqlite v1.22.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=

2862
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
// Package cmd is the sendmail cli
package cmd
/**
@@ -13,10 +14,18 @@ import (
"os"
"os/user"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
flag "github.com/spf13/pflag"
)
var (
// Verbose flag
Verbose bool
fromAddr string
)
// Run the Mailpit sendmail replacement.
func Run() {
host, err := os.Hostname()
@@ -30,7 +39,10 @@ func Run() {
username = user.Username
}
fromAddr := username + "@" + host
if fromAddr == "" {
fromAddr = username + "@" + host
}
smtpAddr := "localhost:1025"
var recip []string
@@ -42,25 +54,32 @@ func Run() {
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
}
var verbose bool
// 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.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender address")
flag.StringVarP(&smtpAddr, "smtp-addr", "S", 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
// set the default help
flag.Usage = func() {
fmt.Printf("A sendmail command replacement for Mailpit (%s).\n\n", config.Version)
fmt.Printf("Usage:\n %s [flags] [recipients]\n", os.Args[0])
fmt.Println("\nFlags:")
flag.PrintDefaults()
}
flag.Parse()
// allow recipient to be passed as an argument
recip = flag.Args()
if verbose {
fmt.Fprintln(os.Stderr, smtpAddr, fromAddr)
if Verbose {
fmt.Fprintln(os.Stdout, smtpAddr, fromAddr)
}
body, err := ioutil.ReadAll(os.Stdin)
@@ -75,13 +94,30 @@ 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(recip) > 0 {
addresses = recip
} 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)

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 = ""

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

@@ -0,0 +1,105 @@
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 err
}
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 err
}
}
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 == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
if a != nil {
if err = c.Auth(a); err != nil {
return err
}
}
if err = c.Mail(from); err != nil {
return err
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}

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: %d", 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

@@ -2,6 +2,8 @@
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';
@@ -10,7 +12,9 @@ export default {
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,
}
},
@@ -76,7 +83,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";
@@ -87,6 +94,7 @@ export default {
});
this.connect();
this.getUISettings();
this.loadMessages();
},
@@ -147,6 +155,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 +207,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 +221,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;
@@ -383,10 +404,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 +430,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 +578,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 +641,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>
@@ -693,10 +766,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) }}
@@ -706,7 +779,7 @@ export default {
<template v-if="!message && !selected.length">
<button 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>
@@ -718,19 +791,19 @@ export default {
<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">
@@ -963,4 +1036,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,7 @@
// 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;

View File

@@ -32,7 +32,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

@@ -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 () {
@@ -179,7 +195,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 +211,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 +223,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>
@@ -245,47 +269,63 @@ 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"
<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>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
<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 " seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
</div>
<Attachments v-if=" allAttachments(message).length " :message=" message "
:attachments=" allAttachments(message) "></Attachments>
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
tabindex="0" v-if=" message.HTML ">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class="message.HTML == '' ? 'show' : ''">
:class=" message.HTML == '' ? 'show' : '' ">
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
<Attachments v-if=" allAttachments(message).length " :message=" message "
:attachments=" allAttachments(message) "></Attachments>
</div>
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if="loadHeaders" :message="message"></Headers>
<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"
<iframe v-if=" srcURI " :src=" srcURI " v-on:load=" resizeIframe " seamless frameborder="0"
style="width: 100%; height: 300px;" id="message-src"></iframe>
</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,55 @@ 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
// Subject string
// Size int
// Inline int
// Attachments int
}
// InitDB will initialise the database
@@ -144,6 +179,8 @@ func InitDB() error {
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
@@ -174,7 +211,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 +226,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
}
}
@@ -237,8 +273,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 +301,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 +322,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 +354,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
}
@@ -342,19 +400,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 +427,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 +472,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 +885,16 @@ func IsUnread(id string) bool {
return unread == 1
}
// MessageIDExists blaah
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

@@ -12,7 +12,7 @@ import (
"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+`)
@@ -61,7 +61,7 @@ func findTags(message *[]byte) []string {
return tags
}
// 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{}

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

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