Compare commits

...

307 Commits

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

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

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

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

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

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

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

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

Resolves #13
2022-09-19 22:17:20 +12:00
Ralph Slooten
388bea740b UI: Add documentation link (wiki) 2022-09-17 08:09:22 +12:00
Ralph Slooten
583df9ee1f Merge tag '1.1.5' into develop
Release 1.1.5
2022-09-16 23:27:57 +12:00
Ralph Slooten
8f05b97947 Merge branch 'release/1.1.5' 2022-09-16 23:27:55 +12:00
Ralph Slooten
8bdd0cc635 Release 1.1.5 2022-09-16 23:27:55 +12:00
Ralph Slooten
a372e8150e Update README 2022-09-16 23:15:40 +12:00
Ralph Slooten
2bc2660ad5 Fix count of selected messages 2022-09-16 21:54:25 +12:00
Ralph Slooten
5d6aa7c48a UI: Support for inline images using filenames instead of cid
Some historic email programs use the attachment filename instead of a reference cid for inline images (eg: Outlook).
2022-09-16 18:40:29 +12:00
Ralph Slooten
997e041042 Build: Switch to esbuild-sass-plugin 2022-09-16 14:59:28 +12:00
Ralph Slooten
5c362c1430 Merge tag '1.1.4' into develop
Release 1.1.4
2022-09-15 21:54:19 +12:00
Ralph Slooten
9219b2d411 Merge branch 'release/1.1.4' 2022-09-15 21:54:16 +12:00
Ralph Slooten
86abc7ea68 Release 1.1.4 2022-09-15 21:54:16 +12:00
Ralph Slooten
867dbf41d5 UI: Minor UI color change & unread count position adjustment 2022-09-15 21:52:22 +12:00
Ralph Slooten
51e458ad57 Security: Add restrictive HTTP Content-Security-Policy 2022-09-15 21:23:27 +12:00
Ralph Slooten
d29a7d6218 Update README 2022-09-15 17:40:39 +12:00
Ralph Slooten
f6a8de3215 UI: Add favicon unread message counter 2022-09-14 22:37:47 +12:00
Ralph Slooten
4e2e59ec87 Update README 2022-09-14 17:25:56 +12:00
Ralph Slooten
6aeebb9824 UI: Remove left & right borders (message list) 2022-09-14 17:14:36 +12:00
Ralph Slooten
a426f64795 Feature: Add --quiet flag to display only errors 2022-09-14 17:14:26 +12:00
Ralph Slooten
b228c9477e Merge branch 'release/1.1.3' 2022-09-14 16:46:50 +12:00
Ralph Slooten
d70f2fd196 Release 1.1.3 2022-09-14 16:46:50 +12:00
Ralph Slooten
0da89d91dd Fix: Update message download link 2022-09-14 16:45:23 +12:00
Ralph Slooten
edab9e1b6b Merge tag '1.1.2' into develop
Release 1.1.2
2022-09-14 13:44:25 +12:00
Ralph Slooten
66aead387e Merge branch 'release/1.1.2' 2022-09-14 13:44:23 +12:00
Ralph Slooten
efe1ac732e Release 1.1.2 2022-09-14 13:44:23 +12:00
Ralph Slooten
33dcd489eb UI: Allow reverse proxy subdirectories 2022-09-14 13:43:38 +12:00
Ralph Slooten
6b2e5b2e41 mod tidy 2022-09-14 13:42:13 +12:00
Ralph Slooten
812c9b99d1 Update installation instructions 2022-09-14 12:11:52 +12:00
Ralph Slooten
8202c94a43 Merge tag '1.1.1' into develop
Release 1.1.1
2022-09-12 22:12:54 +12:00
Ralph Slooten
c1d4a73440 Merge branch 'release/1.1.1' 2022-09-12 22:12:51 +12:00
Ralph Slooten
8e100ff21b Release 1.1.1 2022-09-12 22:12:51 +12:00
Ralph Slooten
088b772de5 UI: Attachment icons and image thumbnails 2022-09-12 22:11:51 +12:00
Ralph Slooten
faf8bd4a08 Merge tag '1.1.0' into develop
Release 1.1.0
2022-09-10 00:00:42 +12:00
Ralph Slooten
0e83a5a985 Merge branch 'release/1.1.0' 2022-09-10 00:00:36 +12:00
Ralph Slooten
3ee91eb6c8 Release 1.1.0 2022-09-10 00:00:36 +12:00
Ralph Slooten
5cd0a6e2f3 UI tweaks 2022-09-09 23:57:53 +12:00
Ralph Slooten
fea733a43e UI: HTML source & highlighting 2022-09-09 23:34:35 +12:00
Ralph Slooten
d4e520772e Remove redundant npm dependency ('remove') 2022-09-04 22:04:26 +12:00
Ralph Slooten
e4a7212f89 Reload UI on prev/next message 2022-09-03 23:02:10 +12:00
Ralph Slooten
e6a5fceedd UI: Add previous/next message links 2022-09-03 22:46:38 +12:00
Ralph Slooten
bf4d5fbc6b Update changelog format 2022-09-03 19:20:51 +12:00
Ralph Slooten
93c3dec66e Merge tag '1.0.0' into develop
Release 1.0.0
2022-09-03 19:13:46 +12:00
Ralph Slooten
98026e0685 Merge branch 'release/1.0.0' 2022-09-03 19:13:42 +12:00
Ralph Slooten
ecd3a97853 Release 1.0.0 2022-09-03 19:13:20 +12:00
Ralph Slooten
695270e515 Merge branch 'feature/multi-selection' into develop 2022-09-03 19:09:57 +12:00
Ralph Slooten
43403bc6f7 Feature: Multiple message selection for group actions using shift/ctrl click
Allow group actions for deleting & marking as read/unread, resolves #11
2022-09-03 19:01:54 +12:00
Ralph Slooten
6dbdbf1637 UI: Post data using 'application/json' 2022-09-03 19:01:54 +12:00
Ralph Slooten
3c81e152e6 UI: Display unknown recipients as as Undisclosed recipients 2022-09-03 19:01:53 +12:00
Ralph Slooten
9501b460c5 Update README 2022-09-03 19:01:53 +12:00
Ralph Slooten
6233cb1e07 UI: Update frontend modules & esbuild 2022-09-03 19:01:53 +12:00
Ralph Slooten
f64f377199 Feature: Search parser improvements 2022-09-03 19:01:34 +12:00
Ralph Slooten
f872424526 UI: Update frontend modules & esbuild 2022-09-01 22:01:56 +12:00
Ralph Slooten
5d530edfab feature: Search parser improvements 2022-09-01 21:45:35 +12:00
Ralph Slooten
12c54f4bb3 Update changelog format 2022-08-30 23:52:53 +12:00
Ralph Slooten
23e47c567a Merge tag '1.0.0-beta1' into develop
Release 1.0.0-beta1
2022-08-30 23:15:36 +12:00
Ralph Slooten
b6940eccff Merge branch 'release/1.0.0-beta1' 2022-08-30 23:15:31 +12:00
Ralph Slooten
eb796924b1 Merge branch 'feature/sqlite' into develop 2022-08-30 23:10:25 +12:00
Ralph Slooten
54ba59872e Deprecate --data flag (replaced by --db-file) 2022-08-30 23:02:56 +12:00
Ralph Slooten
eff483c1c4 feature: Switch backend storage to use SQLite
BREAKING CHANGE: This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
2022-08-30 22:42:43 +12:00
Ralph Slooten
9f5d329105 Update CHANGELOG format 2022-08-29 22:44:32 +12:00
Ralph Slooten
77e6b88c5d UI: Resize preview iframe on load 2022-08-29 22:22:07 +12:00
Ralph Slooten
5a9fd0686e Update README 2022-08-18 21:41:37 +12:00
Ralph Slooten
3054dfe79e Catch error in testing 2022-08-18 21:28:46 +12:00
Ralph Slooten
40cb76810e Merge tag '0.1.5' into develop
Release 0.1.5
2022-08-16 08:17:24 +12:00
Ralph Slooten
8b6b6640d5 Merge branch 'release/0.1.5' 2022-08-16 08:17:21 +12:00
Ralph Slooten
a8945bd303 Release 0.1.5 2022-08-16 08:17:20 +12:00
Ralph Slooten
53e199b20f Better error handling on failed upgrade if file corrupt 2022-08-16 08:16:03 +12:00
Ralph Slooten
a6693481fa Quote exact string matches in search test 2022-08-12 10:19:49 +12:00
Ralph Slooten
1aa58eeaaf Feature: Improved message search - any order & phrase quoting 2022-08-12 10:16:21 +12:00
Ralph Slooten
133b36c34c UI: Change breakpoints for mobile view of messages 2022-08-11 00:32:10 +12:00
Ralph Slooten
ed28a4cc0d UI: Resize iframes with viewport resize 2022-08-11 00:31:22 +12:00
Ralph Slooten
bc30b012cf Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:32:02 +12:00
Ralph Slooten
2ae51c3f64 Merge branch 'release/0.1.4' 2022-08-10 20:31:59 +12:00
Ralph Slooten
b6a87b9410 Release 0.1.4 2022-08-10 20:31:59 +12:00
Ralph Slooten
1f7dd0287a Merge branch 'feature/ui-tweaks' into develop 2022-08-10 20:31:25 +12:00
Ralph Slooten
f33cbce63f Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:30:05 +12:00
Ralph Slooten
79b6892320 Merge branch 'release/0.1.4' 2022-08-10 20:30:02 +12:00
Ralph Slooten
799987ecb1 Release 0.1.4 2022-08-10 20:30:01 +12:00
Ralph Slooten
2d57839b3e UI: Mobile compatibility improvements & functionality 2022-08-10 20:21:27 +12:00
Ralph Slooten
86cc237c78 Feature: Email compression in storage
Reduces storage requirements +-25% & speeds up database read & writes by between 25-33%, depending on email content (attachments).
2022-08-10 14:33:16 +12:00
Ralph Slooten
cc15ada304 Testing: Enable testing on feature branches 2022-08-10 09:48:06 +12:00
Ralph Slooten
49bc62f0aa Update screenshot 2022-08-08 23:16:48 +12:00
Ralph Slooten
444b65d371 Testing: Database total/unread statistics tests 2022-08-07 23:07:36 +12:00
Ralph Slooten
15859f7be9 Add Go Report Card 2022-08-07 22:38:23 +12:00
Ralph Slooten
486388a798 Fix typos 2022-08-07 22:35:42 +12:00
Ralph Slooten
9ab28d606a Add privacy classes for screenshots 2022-08-07 13:38:53 +12:00
Ralph Slooten
18b5ce8c18 Add build status to README 2022-08-07 10:57:45 +12:00
Ralph Slooten
93d5289d25 Merge tag '0.1.3' into develop
Release 0.1.3
2022-08-07 10:41:02 +12:00
Ralph Slooten
97bf9c257c Merge branch 'release/0.1.3' 2022-08-07 10:40:59 +12:00
Ralph Slooten
18b0f5b790 Release 0.1.3 2022-08-07 10:40:59 +12:00
Ralph Slooten
94feb2ccaa Update screenshot 2022-08-07 10:38:40 +12:00
Ralph Slooten
aba3c46eb1 Update wording for "no emails/results message" 2022-08-07 10:28:33 +12:00
Ralph Slooten
c9c910ab7c UI: Better error handling when connection to server is broken 2022-08-07 10:21:08 +12:00
Ralph Slooten
29c7295d16 Merge branch 'feature/ui-tweaks' into develop 2022-08-07 10:14:40 +12:00
Ralph Slooten
61e15e4155 UI: Add reset search button 2022-08-07 10:11:48 +12:00
Ralph Slooten
e03618570d UI: Minor UI tweaks 2022-08-07 10:11:21 +12:00
Ralph Slooten
d4cf95363f Feature: Mark all messages as read 2022-08-07 09:34:06 +12:00
Ralph Slooten
f260495495 UI: Update pagination values when new mail arrives when not on first page 2022-08-07 08:38:52 +12:00
Ralph Slooten
d9f1f88107 Merge pull request #6 from KaptinLin/develop
Bugfix: Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables
2022-08-07 08:12:48 +12:00
KaptinLin
09b704bcd7 Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables 2022-08-06 22:34:33 +08:00
Ralph Slooten
a14cdce07f Update disconnected state hover title 2022-08-07 01:15:40 +12:00
Ralph Slooten
9fc5318e86 Merge tag '0.1.2' into develop
Release 0.1.2
2022-08-07 01:08:03 +12:00
74 changed files with 9959 additions and 3077 deletions

View File

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

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

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

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

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

View File

@@ -10,10 +10,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -27,11 +23,12 @@ jobs:
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ steps.tag.outputs.tag }}"
"VERSION=${{ github.ref_name }}"
push: true
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}
tags: axllent/mailpit:latest,axllent/mailpit:${{ github.ref_name }}

View File

@@ -0,0 +1,23 @@
name: Close stale issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v7.0.0
with:
days-before-issue-stale: 30
days-before-issue-close: 7
exempt-issue-labels: "enhancement,bug"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 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
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,29 +10,30 @@ jobs:
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: ["386", amd64, arm64]
goarch: ["386", amd64, arm, arm64]
exclude:
- goarch: "386"
goos: darwin
- goarch: arm64
- goarch: "386"
goos: windows
- goarch: arm
goos: darwin
- goarch: arm
goos: windows
steps:
- uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: 'npm'
- run: echo "Building assets for ${{ github.ref_name }}"
- run: npm install
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.30
- uses: wangyoucao577/go-release-action@v1.36
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
@@ -42,4 +43,5 @@ jobs:
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
extra_files: LICENSE README.md
md5sum: false
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"
overwrite: true
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"

View File

@@ -1,9 +1,9 @@
name: Test
name: Tests
on:
pull_request:
branches: [ develop ]
push:
branches: [ develop ]
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
@@ -24,13 +24,13 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./storage -v
- run: go test ./storage ./server -v
- run: go test ./storage -bench=.
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: 'npm'
- run: npm install
- run: npm run package

3
.gitignore vendored
View File

@@ -2,5 +2,6 @@
/send
/server/ui/dist
/Makefile
/mailpit
/mailpit*
*.old
*.db

View File

@@ -2,8 +2,378 @@
Notable changes to Mailpit will be documented in this file.
## [v1.5.2]
## 0.1.2
### API
- Include Reply-To in message summary (including Web UI)
### UI
- Tab to view formatted message headers
## [v1.5.1]
### Feature
- Add 'o', 'b' & 's' ignored flags for sendmail
### Libs
- Update Go modules
- Update node modules
## [v1.5.0]
### API
- Return received datetime when message does not contain a date header
### Bugfix
- Fix JavaScript error when adding the first tag manually
### Feature
- OpenAPI / Swagger schema
- Download raw message, HTML/text body parts or attachments via single button
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.4.0]
### API
- Return received datetime when message does not contain a date header
### Feature
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.3.11]
### Docker
- Expose default ports (1025/tcp 8025/tcp)
### Feature
- Expand custom webroot path to include a-z A-Z 0-9 _ . - and /
## [v1.3.10]
### Bugfix
- Fix search with existing emails
### Libs
- Update node modules
## [v1.3.9]
### Feature
- Add Cc and Bcc search filters
### Libs
- Update node modules
- Update Go modules
### Pull Requests
- Merge pull request [#44](https://github.com/axllent/mailpit/issues/44) from axllent/dependabot/github_actions/wangyoucao577/go-release-action-1.36
- Merge pull request [#43](https://github.com/axllent/mailpit/issues/43) from axllent/dependabot/github_actions/docker/build-push-action-4
- Merge pull request [#55](https://github.com/axllent/mailpit/issues/55) from axllent/dependabot/go_modules/golang.org/x/image-0.5.0
- Merge pull request [#42](https://github.com/axllent/mailpit/issues/42) from shizunge/dependabot
## [v1.3.8]
### Bugfix
- Restore notification icon
### UI
- Compress SVG icons
## [v1.3.7]
### Feature
- Add Kubernetes API health (livez/readyz) endpoints
### Libs
- Upgrade to esbuild 0.17.5
## [v1.3.6]
### Bugfix
- Correctly index missing 'From' header in database
### Libs
- Update node modules
- Update go modules
## [v1.3.5]
### Bugfix
- Include HTML link text in search data
## [v1.3.4]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.3]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.2]
### Build
- Temporarily disable arm (32) Docker build
## [v1.3.1]
### Bugfix
- Append trailing slash to custom webroot for UI & API
### Libs
- Upgrade esbuild & axios
### UI
- Rename "results" to "result" when singular message returned
## [v1.3.0]
### Build
- Remove duplicate bootstrap CSS
### Libs
- Update go modules
- Update node modules
## [v1.2.9]
### Bugfix
- Delay 200ms to set `target="_blank"` for all rendered email links
## [v1.2.8]
### Bugfix
- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
### Feature
- Message tags and auto-tagging
## [v1.2.7]
### Feature
- Allow custom webroot
## [v1.2.6]
### API
- Provide structs of API v1 responses for use in client code
### Libs
- Update go modules
- Update node modules
## [1.2.5]
### UI
- Broadcast "delete all" action to reload all connected clients
- Load first page if paginated list returns 0 results
- Theme changes
- Bump build action to use node 18
## [1.2.4]
### Bugfix
- Fix mail download link
## [1.2.3]
### API
- Add limit and start parameters to search
### UI
- Prevent double message index request on websocket connect
## [1.2.2]
### API
- Add API endpoint to return message headers
### Libs
- Update go modules
### Testing
- Add API test for raw & message headers
## [1.2.1]
### UI
- Update frontend modules
- Add about app modal with version update notification
## [1.2.0]
### Feature
- Add REST API
### Testing
- Add API tests
### UI
- Changes to use new data API
- Hide delete all / mark all read in message view
## [1.1.7]
### Fix
- Normalize running binary name detection (Windows)
## [1.1.6]
### Fix
- Workaround for Safari source matching bug blocking event listener
### UI
- Add documentation link (wiki)
## [1.1.5]
### Build
- Switch to esbuild-sass-plugin
### UI
- Support for inline images using filenames instead of cid
## [1.1.4]
### Feature
- Add --quiet flag to display only errors
### Security
- Add restrictive HTTP Content-Security-Policy
### UI
- Minor UI color change & unread count position adjustment
- Add favicon unread message counter
- Remove left & right borders (message list)
## [1.1.3]
### Fix
- Update message download link
## [1.1.2]
### UI
- Allow reverse proxy subdirectories
## [1.1.1]
### UI
- Attachment icons and image thumbnails
## [1.1.0]
### UI
- HTML source & highlighting
- Add previous/next message links
## [1.0.0]
### Feature
- Multiple message selection for group actions using shift/ctrl click
- Search parser improvements
### Feature
- Search parser improvements
### UI
- Post data using 'application/json'
- Display unknown recipients as as `Undisclosed recipients`
- Update frontend modules & esbuild
- Update frontend modules & esbuild
## [1.0.0-beta1]
### BREAKING CHANGE
This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
### Feature
- Switch backend storage to use SQLite
### UI
- Resize preview iframe on load
## [0.1.5]
### Feature
- Improved message search - any order & phrase quoting
### UI
- Change breakpoints for mobile view of messages
- Resize iframes with viewport resize
## [0.1.4]
### Feature
- Email compression in storage
### Testing
- Enable testing on feature branches
- Database total/unread statistics tests
### UI
- Mobile compatibility improvements & functionality
## [0.1.3]
### Feature
- Mark all messages as read
### UI
- Better error handling when connection to server is broken
- Add reset search button
- Minor UI tweaks
- Update pagination values when new mail arrives when not on first page
### Pull Requests
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
## [0.1.2]
### Feature
- Optional browser notifications (HTTPS only)
@@ -14,19 +384,19 @@ Notable changes to Mailpit will be documented in this file.
- Use strconv.Atoi() for safe string to int conversions
## 0.1.1
## [0.1.1]
### Bugfix
- Fix env variable for MP_UI_SSL_KEY
## 0.1.0
## [0.1.0]
### Feature
- SMTP STARTTLS & SMTP authentication support
## 0.0.9
## [0.0.9]
### Bugfix
- Include read status in search results
@@ -38,7 +408,7 @@ Notable changes to Mailpit will be documented in this file.
- Memory & physical database tests
## 0.0.8
## [0.0.8]
### Bugfix
- Fix total/unread count after failed message inserts
@@ -47,25 +417,25 @@ Notable changes to Mailpit will be documented in this file.
- Add project links to help in CLI
## 0.0.7
## [0.0.7]
### Bugfix
- Command flag should be `--auth-file`
## 0.0.6
## [0.0.6]
### Bugfix
- Disable CGO when building multi-arch binaries
## 0.0.5
## [0.0.5]
### Feature
- Basic authentication support
## 0.0.4
## [0.0.4]
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
@@ -82,15 +452,16 @@ Notable changes to Mailpit will be documented in this file.
- cater for messages without From email address
## 0.0.3
## [0.0.3]
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
## 0.0.2
## [0.0.2]
### Feature
- Unread statistics

View File

@@ -8,8 +8,7 @@ WORKDIR /app
RUN apk add --no-cache git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
FROM alpine:latest
@@ -17,4 +16,6 @@ COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
EXPOSE 1025/tcp 8025/tcp
ENTRYPOINT ["/mailpit"]

View File

@@ -1,34 +1,72 @@
# Mailpit
# Mailpit - email testing for developers
Mailpit is an email testing tool for developers.
![Tests](https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg)
![Build status](https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg)
![Docker builds](https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg)
![CodeQL](https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/axllent/mailpit)](https://goreportcard.com/report/github.com/axllent/mailpit)
Mailpit is a multi-platform email testing tool & API for developers.
It acts as both an SMTP server, and provides a web interface to view all captured emails.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/docs/screenshot.png)
## Features
- Runs completely on a single binary
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
- 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)
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Email storage either in memory or disk ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
- 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
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- 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))
- A simple REST API ([see docs](docs/apiv1/README.md))
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
## Installation
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
Mailpit runs as a single binary and can be installed in different ways:
### Install via Brew (Mac)
Add the repository to your taps with `brew tap axllent/apps`, and then install Mailpit with `brew install mailpit`.
### Install via bash script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
### Download static binary (Windows, Linux and Mac)
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can extracted and copied to your `$PATH`, or simply run as `./mailpit`.
### Docker
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images).
### Compile from source
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
@@ -53,6 +91,6 @@ You can build a Mailpit-specific sendmail binary from source (see [building from
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
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.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

View File

@@ -1,14 +1,17 @@
// Package cmd is the main application
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/server/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/spf13/cobra"
)
@@ -71,9 +74,69 @@ func init() {
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// load and warn deprecated ENV vars
initDeprecatedConfigFromEnv()
// load ENV vars
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages 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(&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().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().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// deprecated flags 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ssl-cert", config.UITLSCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ssl-key", config.UITLSKey, "SSL key - requires ssl-cert")
rootCmd.Flags().Lookup("auth-file").Hidden = true
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-tls-key"
// deprecated flags 2022/08/30
rootCmd.Flags().StringVar(&config.DataFile, "data", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().Lookup("data").Hidden = true
rootCmd.Flags().Lookup("data").Deprecated = "use --db-file"
// deprecated flags 2023/03/12
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-ssl-key", config.SMTPTLSKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().Lookup("ui-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ui-ssl-key").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-key").Deprecated = "use --ui-tls-key"
rootCmd.Flags().Lookup("smtp-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
}
// Load settings from environment
func initConfigFromEnv() {
// defaults from envars if provided
if len(os.Getenv("MP_DATA_DIR")) > 0 {
config.DataDir = os.Getenv("MP_DATA_DIR")
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.DataFile = os.Getenv("MP_DATA_FILE")
}
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
@@ -84,55 +147,102 @@ func init() {
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
// UI
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
}
if len(os.Getenv("MP_UI_TLS_CERT")) > 0 {
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
}
if len(os.Getenv("MP_UI_TLS_KEY")) > 0 {
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
}
// SMTP
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
}
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
if len(os.Getenv("MP_SMTP_TLS_CERT")) > 0 {
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
}
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
if len(os.Getenv("MP_SMTP_TLS_KEY")) > 0 {
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
}
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_QUIET") {
config.QuietLogging = true
}
if getEnabledFromEnv("MP_VERBOSE") {
config.VerboseLogging = true
}
}
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
fmt.Println("ENV MP_AUTH_FILE has been deprecated, use MP_UI_AUTH_FILE")
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_SSL_CERT")
fmt.Println("ENV MP_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_SSL_CERT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_SSL_KEY")
fmt.Println("ENV MP_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_TLS_KEY")
}
// deprecated 2022/08/28
if len(os.Getenv("MP_DATA_DIR")) > 0 {
fmt.Println("ENV MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
config.DataFile = os.Getenv("MP_DATA_DIR")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
}
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
}
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
}
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
}
}
// Wrapper to get a boolean from an environment variable
func getEnabledFromEnv(k string) bool {
if len(os.Getenv(k)) > 0 {
v := strings.ToLower(os.Getenv(k))
return v == "1" || v == "true" || v == "yes"
}
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ui-ssl-key", config.UISSLKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// deprecated 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ssl-cert", config.UISSLCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ssl-key", config.UISSLKey, "SSL key - requires ssl-cert")
rootCmd.Flags().Lookup("auth-file").Hidden = true
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-ssl-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-ssl-key"
return false
}

View File

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

View File

@@ -1,11 +1,16 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
)
@@ -16,23 +21,29 @@ var (
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
// DataDir for mail (optional)
DataDir string
// DataFile for mail (optional)
DataFile string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// 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
// UISSLCert file
UISSLCert string
// UITLSCert file
UITLSCert string
// UISSLKey file
UISSLKey string
// UITLSKey file
UITLSKey string
// UIAuthFile for basic authentication
UIAuthFile string
@@ -40,21 +51,61 @@ var (
// UIAuth used for euthentication
UIAuth *htpasswd.File
// SMTPSSLCert file
SMTPSSLCert string
// Webroot to define the base path for the UI and API
Webroot = "/"
// SMTPSSLKey file
SMTPSSLKey string
// SMTPTLSCert file
SMTPTLSCert string
// SMTPTLSKey file
SMTPTLSKey string
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuth used for euthentication
SMTPAuth *htpasswd.File
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
// SMTPAuthAcceptAny accepts any username/password including none
SMTPAuthAcceptAny bool
// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// TagRegexp is the allowed tag characters
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []Tag
// 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';"
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// Tag struct
type Tag struct {
Tag string
Match string
}
// 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,}$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
@@ -75,31 +126,31 @@ func VerifyConfig() error {
UIAuth = a
}
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
return errors.New("you must provide both a UI SSL certificate and a key")
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
return errors.New("You must provide both a UI TLS certificate and a key")
}
if UISSLCert != "" {
if !isFile(UISSLCert) {
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
if UITLSCert != "" {
if !isFile(UITLSCert) {
return fmt.Errorf("TLS certificate not found: %s", UITLSCert)
}
if !isFile(UISSLKey) {
return fmt.Errorf("SSL key not found: %s", UISSLKey)
if !isFile(UITLSKey) {
return fmt.Errorf("TLS key not found: %s", UITLSKey)
}
}
if SMTPSSLCert != "" && SMTPSSLKey == "" || SMTPSSLCert == "" && SMTPSSLKey != "" {
return errors.New("you must provide both an SMTP SSL certificate and a key")
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("You must provide both an SMTP TLS certificate and a key")
}
if SMTPSSLCert != "" {
if !isFile(SMTPSSLCert) {
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
if SMTPTLSCert != "" {
if !isFile(SMTPTLSCert) {
return fmt.Errorf("SMTP TLS certificate not found: %s", SMTPTLSCert)
}
if !isFile(SMTPSSLKey) {
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
if !isFile(SMTPTLSKey) {
return fmt.Errorf("SMTP TLS key not found: %s", SMTPTLSKey)
}
}
@@ -108,8 +159,8 @@ func VerifyConfig() error {
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
if SMTPSSLCert == "" {
return errors.New("SMTP authentication requires SMTP encryption")
if SMTPAuthAcceptAny {
return errors.New("SMTP authentication can either use --smtp-auth-file or --smtp-auth-accept-any")
}
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
@@ -119,6 +170,47 @@ func VerifyConfig() error {
SMTPAuth = a
}
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.]`)
if validWebrootRe.MatchString(Webroot) {
return fmt.Errorf("Invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - /]", Webroot)
}
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
SMTPTags = []Tag{}
p := shellwords.NewParser()
if SMTPCLITags != "" {
args, err := p.Parse(SMTPCLITags)
if err != nil {
return fmt.Errorf("Error parsing tags (%s)", err)
}
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := strings.TrimSpace(t[0])
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
if len(match) == 0 {
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, Tag{Tag: tag, Match: match})
} else {
return fmt.Errorf("Error parsing tags (%s)", a)
}
}
}
return nil
}
@@ -131,3 +223,13 @@ func isFile(path string) bool {
return true
}
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}

View File

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

View File

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

116
docs/apiv1/Message.md Normal file
View File

@@ -0,0 +1,116 @@
# Message
## Message summary
Returns a JSON summary of the message and attachments.
**URL** : `api/v1/message/<ID>`
**Method** : `GET`
## Response
**Status** : `200`
```json
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"Read": true,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [],
"Bcc": [],
"ReplyTo": [],
"Subject": "Message subject",
"Date": "2016-09-07T16:46:00+13:00",
"Text": "Plain text MIME part of the email",
"HTML": "HTML MIME part (if exists)",
"Size": 79499,
"Inline": [
{
"PartID": "1.2",
"FileName": "filename.gif",
"ContentType": "image/gif",
"ContentID": "919564503@07092006-1525",
"Size": 7760
}
],
"Attachments": [
{
"PartID": "2",
"FileName": "filename.doc",
"ContentType": "application/msword",
"ContentID": "",
"Size": 43520
}
]
}
```
### Notes
- `Read` - always true (message marked read on open)
- `From` - Name & Address, or null
- `To`, `CC`, `BCC`, `ReplyTo` - Array of Names & Address
- `Date` - Parsed email local date & time from headers
- `Size` - Total size of raw email
- `Inline`, `Attachments` - Array of attachments and inline images.
---
## Attachments
**URL** : `api/v1/message/<ID>/part/<PartID>`
**Method** : `GET`
Returns the attachment using the MIME type provided by the attachment `ContentType`.
---
## Headers
**URL** : `api/v1/message/<ID>/headers`
**Method** : `GET`
Returns all message headers as a JSON array.
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
```json
{
"Content-Type": [
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
],
"Date": [
"Wed, 12 Jul 2006 23:38:30 +1200"
],
"Delivered-To": [
"user@example.com",
"user-alias@example.com"
],
"From": [
"\"User Name\" \\u003remote@example.com\\u003e"
],
"Message-Id": [
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
],
....
}
```
---
## Raw (source) email
**URL** : `api/v1/message/<ID>/raw`
**Method** : `GET`
Returns the original email source including headers and attachments.

166
docs/apiv1/Messages.md Normal file
View File

@@ -0,0 +1,166 @@
# Messages
List & delete messages.
---
## List
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
**URL** : `api/v1/messages`
**Method** : `GET`
### Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
### Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"count": 50,
"start": 0,
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox
- `unread` - Total unread messages in mailbox
- `count` - Number of messages returned in request
- `start` - The offset (default `0`) for pagination
- `Read` - The read/unread status of the message
- `From` - Name & Address, or null if none
- `To`, `CC`, `BCC` - Array of Names & Address
- `Created` - Local date & time the message was received
- `Size` - Total size of raw email in bytes
---
## Delete individual messages
Delete one or more messages by ID.
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": ["<ID>","<ID>"...]
}
```
### Response
**Status** : `200`
---
## Delete all messages
Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely).
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": []
}
```
### Response
**Status** : `200`
---
## Update individual read statuses
Set the read status of one or more messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"read": false
}
```
### Response
**Status** : `200`
---
## Update all messages read status
Set the read status of all messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": [],
"read": false
}
```
### Response
**Status** : `200`

14
docs/apiv1/README.md Normal file
View File

@@ -0,0 +1,14 @@
# API v1
Mailpit provides a simple REST API to access and delete stored messages.
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
You can view the Swagger API documentation directly within Mailpit by going to `http://0.0.0.0:8025/api/v1/`.
The API is split into three main parts:
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
- [Message](Message.md) - Return message data & attachments
- [Tags](Tags.md) - Set message tags
- [Search](Search.md) - Searching messages

69
docs/apiv1/Search.md Normal file
View File

@@ -0,0 +1,69 @@
# Search
**URL** : `api/v1/search?query=<string>`
**Method** : `GET`
The search returns the most recent matches (default 50).
Matching messages are returned in the order of latest received to oldest.
## Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| query | string | true | Search query |
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
## Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"count": 25,
"start": 0,
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Test email",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox (all messages, not search)
- `unread` - Total unread messages in mailbox (all messages, not search)
- `count` - Number of messages returned in request
- `start` - The offset (default `0`) for pagination
- `From` - Singular Name & Address, or null if none
- `To`, `CC`, `BCC` - Array of Name & Address
- `Size` - Total size of raw email in bytes

27
docs/apiv1/Tags.md Normal file
View File

@@ -0,0 +1,27 @@
# Tags
Set message tags.
---
## Update message tags
Set the tags for one or more messages.
If the tags array is empty then all tags are removed from the messages.
**URL** : `api/v1/tags`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"tags": ["<tag>","<tag>"]
}
```
### Response
**Status** : `200`

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

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

33
esbuild.config.mjs Normal file
View File

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

75
go.mod
View File

@@ -3,53 +3,60 @@ module github.com/axllent/mailpit
go 1.18
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.10.0
github.com/k3a/html2text v1.0.8
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/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/ostafen/clover/v2 v2.0.0-alpha.2
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.0
github.com/tg123/go-htpasswd v1.2.1
golang.org/x/text v0.8.0
modernc.org/sqlite v1.21.1
)
require (
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/cznic/ql v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.6+incompatible // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/orderedcode v0.0.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
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-runewidth v0.0.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.3.1 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.7.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b // indirect
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
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
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/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
)

345
go.sum
View File

@@ -1,150 +1,107 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw=
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.11.1 h1:U6ToGVxfxNQQhKrAaGxtwOf7Zqksb8AQ3j1CyAWOk5k=
github.com/jhillyerd/enmime v0.11.1/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/k3a/html2text v1.1.0 h1:ks4hKSTdiTRsLr0DM771mI5TvsoG6zH7m1Ulv7eJRHw=
github.com/k3a/html2text v1.1.0/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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/leporo/sqlf v1.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/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/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/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/ostafen/clover/v2 v2.0.0-alpha.2 h1:PgOWohvpj4qNCyASJ7Q8Ke8ld/wsoi+dQJ05b1ebwus=
github.com/ostafen/clover/v2 v2.0.0-alpha.2/go.mod h1:7UyIG46NglzTDRKB4LJiS/enXpuo67Lj05eM8mdhL6M=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/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=
@@ -154,143 +111,97 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
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=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/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/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/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/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b h1:3ogNYyK4oIQdIKzTu68hQrr4iuVxF3AxKl9Aj/eDrw0=
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4=
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/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/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/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/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=

98
install.sh Normal file
View File

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

12
main.go
View File

@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/cmd"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
@@ -15,10 +16,19 @@ func main() {
}
// running directly
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
cmd.Execute()
} else {
// symlinked
sendmail.Run()
}
}
// Normalize returns a lowercase string stripped of the file extension (if exists).
// Used for detecting Windows commands which ignores letter casing and `.exe`.
// eg: "MaIlpIT.Exe" returns "mailpit"
func normalize(s string) string {
s = strings.ToLower(s)
return strings.TrimSuffix(s, filepath.Ext(s))
}

4509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,23 +3,26 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "node esbuild.config.js",
"watch": "WATCH=true node esbuild.config.js",
"package": "MINIFY=true node esbuild.config.js"
"build": "node esbuild.config.mjs",
"watch": "WATCH=true node esbuild.config.mjs",
"package": "MINIFY=true node esbuild.config.mjs"
},
"dependencies": {
"axios": "^0.27.2",
"axios": "^1.2.1",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.4.41",
"moment": "^2.29.4",
"remove": "^0.1.5",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.14.50",
"esbuild-plugin-sass": "^1.0.1",
"esbuild-plugin-vue-next": "^0.1.4"
"esbuild": "^0.17.5",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -8,12 +8,12 @@ import (
"bytes"
"fmt"
"io/ioutil"
"log"
"net/mail"
"net/smtp"
"os"
"os/user"
"github.com/axllent/mailpit/utils/logger"
flag "github.com/spf13/pflag"
)
@@ -45,11 +45,15 @@ func Run() {
var verbose bool
// override defaults from cli flags
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
flag.BoolP("long-i", "i", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", true, "Ignored. This flag exists for sendmail compatibility.")
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
flag.BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
flag.CommandLine.SortFlags = false
flag.Parse()
// allow recipient to be passed as an argument
@@ -80,6 +84,6 @@ func Run() {
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
log.Fatal(err)
logger.Log().Fatal(err)
}
}

View File

@@ -1,224 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
type messagesResult struct {
Total int `json:"total"`
Unread int `json:"unread"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}
// Return a list of available mailboxes
func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
res, err := storage.ListMailboxes()
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
start, limit := getStartLimit(r)
messages, err := storage.List(mailbox, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
// we will only return up to 200 results
start := 0
limit := 200
messages, err := storage.Search(mailbox, search, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
msg, err := storage.GetMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Download/view an attachment
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(mailbox, id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// View the full email source as plain text
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// Delete all messages in the mailbox
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
err := storage.DeleteAllMessages(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Delete a single message
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.DeleteOneMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark single message as unread
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.UnreadMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

528
server/apiv1/api.go Normal file
View File

@@ -0,0 +1,528 @@
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
// GetMessages returns a paginated list of messages as JSON
func GetMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/messages messages GetMessages
//
// # List messages
//
// Returns messages from the mailbox ordered from newest to oldest.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: start
// in: query
// description: pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: limit results
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Search returns the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/search messages MessagesSummary
//
// # Search messages
//
// Returns the latest messages matching a search.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: search query
// required: true
// type: string
// + name: limit
// in: query
// description: limit results
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
start, limit := getStartLimit(r)
messages, err := storage.Search(search, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = 0
res.Messages = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message Message
//
// # Get message summary
//
// Returns the summary of a message, marking the message as read.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: message id
// required: true
// type: string
//
// Responses:
// 200: Message
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// DownloadAttachment (method: GET) returns the attachment data
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
//
// # Get message attachment
//
// This will return the attachment part using the appropriate Content-Type.
//
// Produces:
// - application/*
// - image/*
// - text/*
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: message id
// required: true
// type: string
// + name: PartID
// in: path
// description: attachment part id
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
fourOFour(w)
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// GetHeaders (method: GET) returns the message headers as JSON
func GetHeaders(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/headers message Headers
//
// # Get message headers
//
// Returns the message headers as an array.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: message id
// required: true
// type: string
//
// Responses:
// 200: MessageHeaders
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
reader := bytes.NewReader(data)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(m.Header)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/raw message Raw
//
// # Get message source
//
// Returns the full email source as plain text.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: message id
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Set("Content-Type", "text/plain")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages Delete
//
// # Delete messages
//
// If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Message ids to delete
// required: false
// type: DeleteRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil || len(data.IDs) == 0 {
if err := storage.DeleteAllMessages(); err != nil {
httpError(w, err.Error())
return
}
} else {
for _, id := range data.IDs {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatus
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Message ids to update
// required: false
// type: SetReadStatusRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
} else {
err := storage.MarkAllUnread()
if err != nil {
httpError(w, err.Error())
return
}
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// SetTags (method: PUT) will set the tags for all provided IDs
func SetTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Message ids to update
// required: true
// type: SetTagsRequest
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if err := storage.SetTags(id, data.Tags); err != nil {
httpError(w, 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")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
}
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
return start, limit
}

74
server/apiv1/info.go Normal file
View File

@@ -0,0 +1,74 @@
package apiv1
import (
"encoding/json"
"net/http"
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/updater"
)
// Response includes the current and latest Mailpit versions, database info, and memory usage
//
// swagger:model AppInformation
type appInformation struct {
// Current Mailpit version
Version string
// Latest Mailpit version
LatestVersion string
// Database path
Database string
// Database size in bytes
DatabaseSize int64
// Total number of messages in the database
Messages int
// Current memory usage in bytes
Memory uint64
}
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get the application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/octet-stream
//
// Schemes: http, https
//
// Responses:
// 200: InfoResponse
// default: ErrorResponse
info := appInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.Memory = m.Sys - m.HeapReleased
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
}
info.Database = config.DataFile
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Messages = storage.CountTotal()
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

39
server/apiv1/structs.go Normal file
View File

@@ -0,0 +1,39 @@
package apiv1
import (
"github.com/axllent/mailpit/storage"
)
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total int `json:"total"`
// Total number of unread messages in mailbox
Unread int `json:"unread"`
// Number of results returned
Count int `json:"count"`
// Pagination offset
Start int `json:"start"`
// All current tags
Tags []string `json:"tags"`
// Messages summary
// in:body
Messages []storage.MessageSummary `json:"messages"`
}
// The following structs & aliases are provided for easy import
// and understanding of the JSON structure.
// MessageSummary - summary of a single message
type MessageSummary = storage.MessageSummary
// Message data
type Message = storage.Message
// Attachment summary
type Attachment = storage.Attachment

View File

@@ -0,0 +1,19 @@
consumes:
- application/json
info:
description: |-
OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).
title: Mailpit API
contact:
name: GitHub
url: https://github.com/axllent/mailpit
license:
name: MIT license
url: https://github.com/axllent/mailpit/blob/develop/LICENSE
version: "v1"
paths: {}
produces:
- application/json
schemes:
- http
swagger: "2.0"

83
server/apiv1/swagger.go Normal file
View File

@@ -0,0 +1,83 @@
package apiv1
// These structs are for the purpose of defining swagger HTTP responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
Body appInformation
}
// Message summary
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
// The message summary
// in: body
Body MessagesSummary
}
// Message headers
// swagger:model MessageHeaders
type messageHeaders map[string][]string
// Delete request
// swagger:model DeleteRequest
type deleteRequest struct {
// ids
// in:body
IDs []string `json:"ids"`
}
// Set read status request
// swagger:model SetReadStatusRequest
type setReadStatusRequest struct {
// Read status
Read bool `json:"read"`
// ids
// in:body
IDs []string `json:"ids"`
}
// Set tags request
// swagger:model SetTagsRequest
type setTagsRequest struct {
// Tags
// in:body
Tags []string `json:"tags"`
// ids
// in:body
IDs []string `json:"ids"`
}
// Binary data reponse inherits the attachment's content type
// swagger:response BinaryResponse
type binaryResponse struct {
// in: body
Body string
}
// Plain text response
// swagger:response TextResponse
type textResponse struct {
// in: body
Body string
}
// Error reponse
// swagger:response ErrorResponse
type errorResponse struct {
// The error message
// in: body
Body string
}
// Plain text "ok" reponse
// swagger:response OKResponse
type okResponse struct {
// Default reponse
// in: body
Body string
}

134
server/apiv1/thumbnails.go Normal file
View File

@@ -0,0 +1,134 @@
package apiv1
import (
"bufio"
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net/http"
"strings"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
var (
thumbWidth = 180
thumbHeight = 120
)
// Thumbnail returns a thumbnail image for an attachment (images only)
func Thumbnail(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
//
// # Get an attachment image thumbnail
//
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
//
// Produces:
// - image/jpeg
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: message id
// required: true
// type: string
// + name: PartID
// in: path
// description: attachment part id
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
if !strings.HasPrefix(a.ContentType, "image/") {
blankImage(a, w)
return
}
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf)
if err != nil {
// it's not an image, return default
logger.Log().Warning(err)
blankImage(a, w)
return
}
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)
// paste the original over the top
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
blankImage(a, w)
return
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}
// Return a blank image instead of an error when file or image not supported
func blankImage(a *enmime.Part, w http.ResponseWriter) {
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
img := image.NewRGBA(rect)
background := color.RGBA{255, 255, 255, 255}
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
var b bytes.Buffer
foo := bufio.NewWriter(&b)
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}

View File

@@ -0,0 +1,8 @@
package handlers
import "net/http"
// Healthz is a liveness probe
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}

View File

@@ -0,0 +1,17 @@
package handlers
import (
"net/http"
"sync/atomic"
)
// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic
func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if isReady == nil || !isReady.Load().(bool) {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}

View File

@@ -3,18 +3,18 @@ package server
import (
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/mux"
)
@@ -23,6 +23,9 @@ var embeddedFS embed.FS
// Listen will start the httpd
func Listen() {
isReady := &atomic.Value{}
isReady.Store(false)
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err)
@@ -33,31 +36,60 @@ func Listen() {
go websockets.MessageHub.Run()
r := mux.NewRouter()
r.HandleFunc("/api/mailboxes", middleWareFunc(apiListMailboxes))
r.HandleFunc("/api/{mailbox}/messages", middleWareFunc(apiListMailbox))
r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox))
r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll))
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource))
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne))
r.HandleFunc("/api/{mailbox}/{id}/unread", middleWareFunc(apiUnreadOne))
r.HandleFunc("/api/{mailbox}/{id}", middleWareFunc(apiOpenMessage))
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
r := defaultRoutes()
// kubernetes probes
r.HandleFunc("/livez", handlers.HealthzHandler)
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
http.Handle("/", r)
if config.UIAuthFile != "" {
logger.Log().Info("[http] enabling web UI basic authentication")
}
if config.UISSLCert != "" && config.UISSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
// Mark the application here as ready
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().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
logger.Log().Infof("[http] starting server on http://%s%s", config.HTTPListen, config.Webroot)
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
func defaultRoutes() *mux.Router {
r := mux.NewRouter()
// API V1
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT")
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}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
return r
}
// BasicAuthResponse returns an basic auth response to the browser
@@ -80,6 +112,9 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
// and gzip compression.
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -110,6 +145,8 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -136,34 +173,12 @@ func middlewareHandler(h http.Handler) http.Handler {
})
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
// Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
return start, limit
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

316
server/server_test.go Normal file
View File

@@ -0,0 +1,316 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/storage"
"github.com/jhillyerd/enmime"
)
var (
putDataStruct struct {
Read bool `json:"read"`
IDs []string `json:"ids"`
}
)
func Test_APIv1(t *testing.T) {
setup()
defer storage.Close()
r := defaultRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// check count of empty database
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// store this for later tests
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// read first 10
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for indx, msg := range m.Messages {
if indx == 10 {
break
}
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
t.Errorf(err.Error())
}
// test RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
}
// test headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error())
}
// store for later
putIDS = append(putIDS, msg.ID)
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// mark first 10 as unread
t.Log("Mark first 10 as unread")
putData := putDataStruct
putData.IDs = putIDS
j, err := json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark first 10 as read
t.Log("Mark first 10 as read")
putData.Read = true
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// search
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
// delete first 10
t.Log("Delete first 10")
_, err = clientDelete(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 90)
// mark all as read
putData.Read = true
putData.IDs = []string{}
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
t.Log("Mark all read")
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
// delete all
t.Log("Delete all messages")
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
}
func setup() {
config.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := storage.InitDB(); err != nil {
panic(err)
}
}
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
m := apiv1.MessagesSummary{}
data, err := clientGet(uri)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, unread, m.Unread, "wrong unread count")
assertEqual(t, total, m.Total, "wrong total count")
}
func assertSearchEqual(t *testing.T, uri, query string, count int) {
t.Logf("Test search: %s", query)
m := apiv1.MessagesSummary{}
limit := fmt.Sprintf("%d", count)
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, m.Count, "wrong search results count")
}
func insertEmailData(t *testing.T) {
for i := 0; i < 100; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := storage.Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
m := apiv1.MessagesSummary{}
data, err := clientGet(url)
if err != nil {
return m, err
}
if err := json.Unmarshal(data, &m); err != nil {
return m, err
}
return m, nil
}
func clientGet(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
return data, err
}
func clientDelete(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("DELETE", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
return data, err
}
func clientPut(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("PUT", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
return data, err
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

115
server/smtpd/smtpd.go Normal file
View File

@@ -0,0 +1,115 @@
// Package smtpd is the SMTP daemon
package smtpd
import (
"bytes"
"net"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/mhale/smtpd"
)
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())
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())
}
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)
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))
if allow {
logger.Log().Debugf("[smtp] 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))
}
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))
return true, nil
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtp] enabling login auth via %s (insecure)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtp] enabling all auth (insecure)")
}
} else {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtp] enabling login auth via %s (TLS)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtp] enabling any auth (TLS)")
}
}
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}
if config.SMTPAuthFile != "" {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandlerAny
}
if config.SMTPTLSCert != "" {
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
return err
}
}
return srv.ListenAndServe()
}
func cleanIP(i net.Addr) string {
parts := strings.Split(i.String(), ":")
return parts[0]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/bootstrap.scss";
import "./assets/styles.scss";
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
createApp(App).mount('#app')
createApp(App).mount('#app');

View File

@@ -1 +1,3 @@
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;

View File

@@ -1,16 +1,48 @@
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
@import "bootstrap";
[v-cloak] {
display: none !important;
}
.navbar-brand {
color: #2d4a5d;
.navbar {
z-index: 99;
img {
width: 40px;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
}
.navbar-brand {
span {
opacity: 0.8;
transition: all 0.5s;
}
&:hover {
span {
opacity: 1;
}
}
}
.nav-tabs .nav-link {
@include media-breakpoint-down(sm) {
// font-size: 14px;
padding-left: 10px;
padding-right: 10px;
}
}
@@ -24,18 +56,18 @@
z-index: 1500;
}
.message.read:not(.active) {
// background: $gray-100;
.message.read:not(.active):not(.selected) {
color: $gray-500;
}
#nav-plain-text,
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-plain-text {
#nav-plain-text .text-view {
white-space: pre-wrap;
}
@@ -43,7 +75,7 @@
margin: 15px 0 0;
th {
padding-right: 10px;
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
@@ -52,3 +84,248 @@
vertical-align: top;
}
}
#nav-html {
padding-right: 1.5rem;
}
#preview-html {
min-height: 300px;
}
.list-group-item.message:first-child {
border-top: 0;
}
.message.selected {
background: $gray-300;
.text-muted {
color: $body-color !important;
}
&.read {
b {
font-weight: normal;
}
}
}
body.blur {
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
.form-select.tag-selector {
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
color: #000;
background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.function-name,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.function,
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
}
.token.bold {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]:after,
pre[class*="language-"]:before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}

1
server/ui-src/docs.js Normal file
View File

@@ -0,0 +1 @@
import "rapidoc";

View File

@@ -1,4 +1,7 @@
import axios from 'axios'
import axios from 'axios';
import { Modal } from 'bootstrap';
import moment from 'moment';
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing
@@ -8,132 +11,199 @@ FakeModal.prototype.show = function () { alert('open fake modal') }
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0,
}
},
data() {
return {
loading: 0
}
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error)
} else {
alert(error.response.data);
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
alert('Error sending data to the server. Please try again.');
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
}
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return bootstrap.Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error);
} else {
alert(error.response.data);
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
alert('Error sending data to the server. Please try again.');
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
}
},
// generic modal get/set function
offcanvas: function (id) {
var e = document.getElementById(id);
if (e) {
return bootstrap.Offcanvas.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
/**
* Axios GET request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios GET request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios Post request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, values, callback) {
let self = this;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
params.append(key, value);
}
self.loading++;
axios.post(url, params)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios POST request
*
* @params string url
* @params array object/array values
* @params function callback function
*/
post: function (url, data, callback) {
let self = this;
self.loading++;
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios DELETE request (REST only)
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
delete: function (url, values, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
}
}
/**
* Axios DELETE request (REST only)
*
* @params string url
* @params array object/array values
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios PUT request (REST only)
*
* @params string url
* @params array object/array values
* @params function callback function
*/
put: function (url, data, callback) {
let self = this;
self.loading++;
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
allAttachments: function (message) {
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
isImage(a) {
return a.ContentType.match(/^image\//);
},
attachmentIcon: function (a) {
let ext = a.FileName.split('.').pop().toLowerCase();
if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill';
}
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
return 'bi-file-pdf-fill';
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill';
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill';
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill';
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill';
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill';
}
if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill';
}
if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill';
}
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
return 'bi-file-text-fill';
}
return 'bi-file-arrow-down-fill';
}
}
}
export default commonMixins
export default commonMixins;

View File

@@ -0,0 +1,37 @@
<script>
import commonMixins from '../mixins.js';
export default {
props: {
message: Object,
attachments: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="'api/v1/message/'+message.ID+'/part/'+part.PartID" class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
<img v-if="isImage(part)" :src="'api/v1/message/'+message.ID+'/part/'+part.PartID+'/thumb'" class="card-img-top" alt="">
<img v-else src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" class="card-img-top" alt="">
<div class="icon" v-if="!isImage(part)">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
<p class="mb-1 text-muted">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</div>
</a>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script>
import commonMixins from '../mixins.js';
export default {
props: {
message: Object
},
mixins: [commonMixins],
data() {
return {
headers: false
}
},
mounted() {
let self = this;
let uri = 'api/v1/message/' + self.message.ID + '/headers';
self.get(uri, false, function (response) {
self.headers = response.data;
});
},
}
</script>
<template>
<div v-if="headers" class="small">
<div v-for="vals, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
<div class="col-md-8 col-lg-9 col-xl-10 text-muted">
<div v-for="x in vals" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>
</template>

View File

@@ -1,173 +1,292 @@
<script>
import commonMixins from '../mixins.js';
import moment from 'moment'
import Prism from "prismjs";
import Tags from "bootstrap5-tags";
import Attachments from './Attachments.vue';
import Headers from './Headers.vue';
export default {
props: {
message: Object,
mailbox: Object,
existingTags: Array
},
components: {
Attachments,
Headers
},
mixins: [commonMixins],
data() {
return {
srcURI: false,
iframes: [], // for resizing
showTags: false, // to force rerendering of component
messageTags: [],
allTags: [],
loadHeaders: false
}
},
mounted() {
var self = this;
window.addEventListener("resize", self.resizeIframes);
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
watch: {
message: {
handler() {
let self = this;
self.showTags = false;
self.messageTags = self.message.Tags;
self.allTags = self.existingTags;
self.loadHeaders = false;
// delay to select first tab and add HTML highlighting (prev/next)
self.$nextTick(function () {
self.renderUI();
self.showTags = true;
self.$nextTick(function () {
Tags.init("select[multiple]");
});
});
},
// force eager callback execution
immediate: true
},
messageTags() {
// save changed to tags
if (this.showTags) {
this.saveTags();
}
}, 200);
}
},
var tabEl = document.getElementById('nav-source-tab');
tabEl.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/' + self.mailbox + '/' + self.message.ID + '/source';
mounted() {
let self = this;
self.showTags = false;
self.allTags = self.existingTags;
window.addEventListener("resize", self.resizeIframes);
self.renderUI();
let headersTab = document.getElementById('nav-headers-tab');
headersTab.addEventListener('shown.bs.tab', function (event) {
self.loadHeaders = true;
});
let rawTab = document.getElementById('nav-raw-tab');
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw';
self.resizeIframes();
});
self.showTags = true;
self.$nextTick(function () {
Tags.init("select[multiple]");
});
},
unmounted: function () {
window.removeEventListener("resize", this.resizeIframes);
},
methods: {
resizeIframe: function(el) {
renderUI: function () {
let self = this;
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
document.getElementById('message-view').scrollTop = 0;
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () {
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
self.resizeIframes();
}
}, 200);
// html highlighting
window.Prism = window.Prism || {};
window.Prism.manual = true;
Prism.highlightAll();
},
resizeIframe: function (el) {
let i = el.target;
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
},
allAttachments: function(message){
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
resizeIframes: function () {
let h = document.getElementById('preview-html');
if (h) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
let s = document.getElementById('message-src');
if (s) {
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
messageDate: function(d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
saveTags: function () {
let self = this;
var data = {
ids: [this.message.ID],
tags: this.messageTags
}
self.put('api/v1/tags', data, function (response) {
self.scrollInPlace = true;
self.$emit('loadMessages');
});
}
}
}
</script>
<template>
<div v-if="message" class="mh-100" style="overflow-y: scroll;">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td>
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td>
<span v-for="(t, i) in message.To">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
</tbody>
</table>
<div v-if="message" id="message-view" class="mh-100" style="overflow-y: scroll;">
<div class="row w-100">
<div class="col-md">
<table class="messageHeaders">
<tbody>
<tr>
<th class="small">From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address" class="small">
&lt;{{ message.From.Address }}&gt;
</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
<template v-if="i > 0">, </template>
<span class="text-nowrap">{{ t.Name + " &lt;" + t.Address + "&gt;" }}</span>
</span>
<span v-else>Undisclosed recipients</span>
</td>
</tr>
<tr v-if="message.Cc && message.Cc.length" class="small">
<th>Cc</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr v-if="message.Bcc && message.Bcc.length" class="small">
<th>Bcc</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
<th class="text-nowrap">Reply-To</th>
<td class="privacy text-muted">
<span v-for="(t, i) in message.ReplyTo">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
<tr class="d-md-none small">
<th class="small">Date</th>
<td>{{ messageDate(message.Date) }}</td>
</tr>
<tr class="small" v-if="showTags">
<th>Tags</th>
<td>
<select class="form-select small tag-selector" v-model="messageTags" multiple
data-allow-new="true" data-clear-end="true" data-allow-clear="true"
data-placeholder="Add tags..." data-badge-style="secondary"
data-regex="^([a-zA-Z0-9\-\ \_]){3,}$" data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allTags" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Please select a valid tag.</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-auto d-none d-md-block text-end mt-md-3">
<div class="mt-2 mt-md-0" v-if="allAttachments(message)">
<span class="badge rounded-pill text-bg-secondary p-2">
Attachment<span v-if="allAttachments(message).length > 1">s</span>
({{ allAttachments(message).length }})
</span>
</div>
</div>
</div>
<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" :disabled="message.HTML == ''" :class="message.HTML == '' ? 'disabled':''">HTML</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':''">Plain<span class="d-none d-md-inline"> text</span></button>
<button class="nav-link" id="nav-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-source" type="button" role="tab" aria-controls="nav-source"
aria-selected="false">Source</button>
<button class="nav-link" id="nav-mime-tab" data-bs-toggle="tab" data-bs-target="#nav-mime"
type="button" role="tab" aria-controls="nav-mime" aria-selected="false"
:disabled="!allAttachments(message)" :class="!allAttachments(message) ? 'disabled':''"
>Attachments <span v-if="allAttachments(message)">({{allAttachments(message).length}})</span></button>
<div class="d-none d-lg-block ms-auto small mt-3 me-2 text-muted">
<small>{{ messageDate(message.Date) }}</small>
</div>
<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>
<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">
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>
<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">
<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>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel"
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
{{ message.Text }}
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-source" role="tabpanel" aria-labelledby="nav-source-tab"
tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class="message.HTML == '' ? 'show' : ''">
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-mime" role="tabpanel" aria-labelledby="nav-mime-tab"
tabindex="0">
<div v-if="allAttachments(message)" v-for="part in allAttachments(message)" class="mime-part mb-2">
<a :href="'api/'+mailbox+'/'+message.ID+'/part/'+part.PartID" type="button"
class="btn btn-outline-secondary btn-sm me-2" target="_blank">
<i class="bi bi-file-arrow-down-fill"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</a>
<small class="text-muted">{{ getFileSize(part.Size) }}</small>
</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>
</div>
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe" seamless frameborder="0"
style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<script>
import commonMixins from '../mixins.js';
export default {
props: {
message: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="card mt-4">
<div class="card-body text-muted small">
<p class="card-text">
<b>Message date:</b><br>
<small>{{ messageDate(message.Date) }}</small>
</p>
<p class="card-text">
<b>Size:</b> {{ getFileSize(message.Size) }}
</p>
<p class="card-text" v-if="allAttachments(message).length">
<b>Attachments:</b> {{ allAttachments(message).length }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Mailpit API v1 documentation</title>
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="../../favicon.svg">
<script src="../../dist/docs.js"></script>
</head>
<body>
<rapi-doc id="thedoc" spec-url="swagger.json" theme="light" layout="column" render-style="read" load-fonts="false"
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">
<div slot="header">Mailpit API v1 documentation</div>
<a target='_blank' href="../../" slot="logo">
<img src="../../mailpit.svg" width="40" alt="Mailpit" />
</a>
</rapi-doc>
</body>
</html>

View File

@@ -0,0 +1,827 @@
{
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"http"
],
"swagger": "2.0",
"info": {
"description": "OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).",
"title": "Mailpit API",
"contact": {
"name": "GitHub",
"url": "https://github.com/axllent/mailpit"
},
"license": {
"name": "MIT license",
"url": "https://github.com/axllent/mailpit/blob/develop/LICENSE"
},
"version": "v1"
},
"paths": {
"/api/v1/info": {
"get": {
"description": "Returns basic runtime information, message totals and latest release version.",
"produces": [
"application/octet-stream"
],
"schemes": [
"http",
"https"
],
"tags": [
"application"
],
"summary": "Get the application information",
"operationId": "AppInformation",
"responses": {
"200": {
"$ref": "#/responses/InfoResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/message/{ID}": {
"get": {
"description": "Returns the summary of a message, marking the message as read.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Get message summary",
"operationId": "Message",
"parameters": [
{
"type": "string",
"description": "message id",
"name": "ID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Message",
"schema": {
"$ref": "#/definitions/Message"
}
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/message/{ID}/headers": {
"get": {
"description": "Returns the message headers as an array.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Get message headers",
"operationId": "Headers",
"parameters": [
{
"type": "string",
"description": "message id",
"name": "ID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "MessageHeaders",
"schema": {
"$ref": "#/definitions/MessageHeaders"
}
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/message/{ID}/part/{PartID}": {
"get": {
"description": "This will return the attachment part using the appropriate Content-Type.",
"produces": [
"application/*",
"image/*",
"text/*"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Get message attachment",
"operationId": "Attachment",
"parameters": [
{
"type": "string",
"description": "message id",
"name": "ID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "attachment part id",
"name": "PartID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/BinaryResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/message/{ID}/part/{PartID}/thumb": {
"get": {
"description": "This will return a cropped 180x120 JPEG thumbnail of an image attachment.\nIf the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.",
"produces": [
"image/jpeg"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Get an attachment image thumbnail",
"operationId": "Thumbnail",
"parameters": [
{
"type": "string",
"description": "message id",
"name": "ID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "attachment part id",
"name": "PartID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/BinaryResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/message/{ID}/raw": {
"get": {
"description": "Returns the full email source as plain text.",
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Get message source",
"operationId": "Raw",
"parameters": [
{
"type": "string",
"description": "message id",
"name": "ID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/TextResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/messages": {
"get": {
"description": "Returns messages from the mailbox ordered from newest to oldest.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"messages"
],
"summary": "List messages",
"operationId": "GetMessages",
"parameters": [
{
"type": "integer",
"default": 0,
"description": "pagination offset",
"name": "start",
"in": "query"
},
{
"type": "integer",
"default": 50,
"description": "limit results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/MessagesSummaryResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
},
"put": {
"description": "If no IDs are provided then all messages are updated.",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"messages"
],
"summary": "Set read status",
"operationId": "SetReadStatus",
"parameters": [
{
"description": "Message ids to update",
"name": "ids",
"in": "body",
"schema": {
"description": "Message ids to update",
"type": "object",
"$ref": "#/definitions/SetReadStatusRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
},
"delete": {
"description": "If no IDs are provided then all messages are deleted.",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"messages"
],
"summary": "Delete messages",
"operationId": "Delete",
"parameters": [
{
"description": "Message ids to delete",
"name": "ids",
"in": "body",
"schema": {
"description": "Message ids to delete",
"type": "object",
"$ref": "#/definitions/DeleteRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/search": {
"get": {
"description": "Returns the latest messages matching a search.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"messages"
],
"summary": "Search messages",
"operationId": "MessagesSummary",
"parameters": [
{
"type": "string",
"description": "search query",
"name": "query",
"in": "query",
"required": true
},
{
"type": "integer",
"default": 50,
"description": "limit results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/MessagesSummaryResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/tags": {
"put": {
"description": "To remove all tags from a message, pass an empty tags array.",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"tags"
],
"summary": "Set message tags",
"operationId": "SetTags",
"parameters": [
{
"description": "Message ids to update",
"name": "ids",
"in": "body",
"required": true,
"schema": {
"description": "Message ids to update",
"type": "object",
"$ref": "#/definitions/SetTagsRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
}
},
"definitions": {
"Address": {
"description": "An address such as \"Barry Gibbs \u003cbg@example.com\u003e\" is represented\nas Address{Name: \"Barry Gibbs\", Address: \"bg@example.com\"}.",
"type": "object",
"title": "Address represents a single mail address.",
"properties": {
"Address": {
"type": "string"
},
"Name": {
"type": "string"
}
},
"x-go-package": "net/mail"
},
"AppInformation": {
"description": "Response includes the current and latest Mailpit versions, database info, and memory usage",
"type": "object",
"properties": {
"Database": {
"description": "Database path",
"type": "string"
},
"DatabaseSize": {
"description": "Database size in bytes",
"type": "integer",
"format": "int64"
},
"LatestVersion": {
"description": "Latest Mailpit version",
"type": "string"
},
"Memory": {
"description": "Current memory usage in bytes",
"type": "integer",
"format": "uint64"
},
"Messages": {
"description": "Total number of messages in the database",
"type": "integer",
"format": "int64"
},
"Version": {
"description": "Current Mailpit version",
"type": "string"
}
},
"x-go-name": "appInformation",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"Attachment": {
"description": "Attachment struct for inline and attachments",
"type": "object",
"properties": {
"ContentID": {
"description": "content id",
"type": "string"
},
"ContentType": {
"description": "content type",
"type": "string"
},
"FileName": {
"description": "file name",
"type": "string"
},
"PartID": {
"description": "attachment part id",
"type": "string"
},
"Size": {
"description": "size in bytes",
"type": "integer",
"format": "int64"
}
},
"x-go-package": "github.com/axllent/mailpit/storage"
},
"DeleteRequest": {
"description": "Delete request",
"type": "object",
"properties": {
"ids": {
"description": "ids\nin:body",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs"
}
},
"x-go-name": "deleteRequest",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"Message": {
"description": "Message data excluding physical attachments",
"type": "object",
"properties": {
"Attachments": {
"description": "Message attachments",
"type": "array",
"items": {
"$ref": "#/definitions/Attachment"
}
},
"Bcc": {
"description": "Bcc addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
},
"Cc": {
"description": "Cc addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
},
"Date": {
"description": "Message date if set, else date received",
"type": "string",
"format": "date-time"
},
"From": {
"$ref": "#/definitions/Address"
},
"HTML": {
"description": "Message body HTML",
"type": "string"
},
"ID": {
"description": "Unique message database id",
"type": "string"
},
"Inline": {
"description": "Inline message attachments",
"type": "array",
"items": {
"$ref": "#/definitions/Attachment"
}
},
"Read": {
"description": "Read status",
"type": "boolean"
},
"ReplyTo": {
"description": "ReplyTo addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
},
"Size": {
"description": "Message size in bytes",
"type": "integer",
"format": "int64"
},
"Subject": {
"description": "Message subject",
"type": "string"
},
"Tags": {
"description": "Message tags",
"type": "array",
"items": {
"type": "string"
}
},
"Text": {
"description": "Message body text",
"type": "string"
},
"To": {
"description": "To addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
}
},
"x-go-package": "github.com/axllent/mailpit/storage"
},
"MessageHeaders": {
"description": "Message headers",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
},
"x-go-name": "messageHeaders",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"MessageSummary": {
"description": "MessageSummary struct for frontend messages",
"type": "object",
"properties": {
"Attachments": {
"description": "Whether the message has any attachments",
"type": "integer",
"format": "int64"
},
"Bcc": {
"description": "Bcc addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
},
"Cc": {
"description": "Cc addresses",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
},
"Created": {
"description": "Created time",
"type": "string",
"format": "date-time"
},
"From": {
"$ref": "#/definitions/Address"
},
"ID": {
"description": "Unique message database id",
"type": "string"
},
"Read": {
"description": "Read status",
"type": "boolean"
},
"Size": {
"description": "Message size in bytes (total)",
"type": "integer",
"format": "int64"
},
"Subject": {
"description": "Email subject",
"type": "string"
},
"Tags": {
"description": "Message tags",
"type": "array",
"items": {
"type": "string"
}
},
"To": {
"description": "To address",
"type": "array",
"items": {
"$ref": "#/definitions/Address"
}
}
},
"x-go-package": "github.com/axllent/mailpit/storage"
},
"MessagesSummary": {
"description": "MessagesSummary is a summary of a list of messages",
"type": "object",
"properties": {
"count": {
"description": "Number of results returned",
"type": "integer",
"format": "int64",
"x-go-name": "Count"
},
"messages": {
"description": "Messages summary\nin:body",
"type": "array",
"items": {
"$ref": "#/definitions/MessageSummary"
},
"x-go-name": "Messages"
},
"start": {
"description": "Pagination offset",
"type": "integer",
"format": "int64",
"x-go-name": "Start"
},
"tags": {
"description": "All current tags",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Tags"
},
"total": {
"description": "Total number of messages in mailbox",
"type": "integer",
"format": "int64",
"x-go-name": "Total"
},
"unread": {
"description": "Total number of unread messages in mailbox",
"type": "integer",
"format": "int64",
"x-go-name": "Unread"
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"SetReadStatusRequest": {
"description": "Set read status request",
"type": "object",
"properties": {
"ids": {
"description": "ids\nin:body",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs"
},
"read": {
"description": "Read status",
"type": "boolean",
"x-go-name": "Read"
}
},
"x-go-name": "setReadStatusRequest",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"SetTagsRequest": {
"description": "Set tags request",
"type": "object",
"properties": {
"ids": {
"description": "ids\nin:body",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs"
},
"tags": {
"description": "Tags\nin:body",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Tags"
}
},
"x-go-name": "setTagsRequest",
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
}
},
"responses": {
"BinaryResponse": {
"description": "Binary data reponse inherits the attachment's content type"
},
"ErrorResponse": {
"description": "Error reponse"
},
"InfoResponse": {
"description": "Application information",
"schema": {
"$ref": "#/definitions/AppInformation"
},
"headers": {
"Body": {
"description": "Application information"
}
}
},
"MessagesSummaryResponse": {
"description": "Message summary",
"schema": {
"$ref": "#/definitions/MessagesSummary"
}
},
"OKResponse": {
"description": "Plain text \"ok\" reponse"
},
"TextResponse": {
"description": "Plain text response"
}
}
}

22
server/ui/favicon.svg Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="460"
viewBox="0 0 132.292 121.708"
version="1.1"
id="svg6"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<path
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
fill-opacity=".941"
fill="#2d4a5f"
id="path2"
style="fill:#415066;fill-opacity:1" />
<path
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
fill="#00b786"
id="path4" />
</svg>

After

Width:  |  Height:  |  Size: 898 B

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="mailpit.svg">
<link rel="icon" href="favicon.svg">
<title>Mailpit</title>
<link rel=stylesheet href="dist/app.css">
</head>

View File

@@ -1,97 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="460"
viewBox="0 0 132.29167 121.70833"
viewBox="0 0 132.292 121.708"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="mailpit.svg"
inkscape:export-filename="/home/ralph/bitmap.png"
inkscape:export-xdpi="176.09"
inkscape:export-ydpi="176.09">
id="svg6"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="90.98717"
inkscape:cy="229.51456"
inkscape:document-units="mm"
inkscape:current-layer="layer2"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
inkscape:window-width="1548"
inkscape:window-height="838"
inkscape:window-x="52"
inkscape:window-y="25"
inkscape:window-maximized="1">
<sodipodi:guide
position="39.014182,62.44412"
orientation="0,1"
id="guide4529"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
style="display:inline"
transform="translate(-55.479864,-26.541592)">
<g
id="g4547"
transform="matrix(1.9570423,0,0,1.9490788,-53.096581,-140.70068)"
style="opacity:1">
<path
sodipodi:nodetypes="cccc"
inkscape:connector-curvature="0"
id="path4534"
d="M 61.775483,85.805801 89.296873,113.46893 116.98363,85.8058 Z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
id="path4540"
d="m 58.113837,90.436008 31.088544,30.616072 31.277529,-30.521576 -0.0945,18.898806 -30.71057,12.56771 7.748511,6.47285 -4.157737,3.07105 -21.26116,0.0945 c -2.471939,-0.0114 -13.222442,-9.40933 -13.890627,-21.16666 z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccczzcccccc"
inkscape:connector-curvature="0"
id="path4542"
d="m 95.532643,122.7713 27.544977,-11.12272 -4.10354,29.40775 -6.05271,-4.68532 c -11.10189,11.88809 -23.124233,13.48775 -34.745034,10.69078 -11.620801,-2.79697 -16.420919,-10.7759 -20.062499,-18.2612 -3.64158,-7.4853 -2.976265,-15.74301 -1.181174,-23.10379 0.577547,5.393 -0.671158,8.37123 3.260045,17.24516 3.224283,5.84857 7.36483,10.47545 13.229166,12.80395 7.102803,3.17859 16.477397,1.7222 21.308409,-1.55916 l 7.276037,-6.2366 z"
style="fill:#00b786;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
</g>
</g>
id="defs10" />
<path
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
fill-opacity=".941"
fill="#2d4a5f"
id="path2"
style="fill:#ffffff;fill-opacity:1" />
<path
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
fill="#00b786"
id="path4" />
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 898 B

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -5,11 +5,11 @@
package websockets
import (
"log"
"net/http"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/websocket"
)
@@ -117,7 +117,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
logger.Log().Error(err)
return
}

View File

@@ -7,8 +7,7 @@ package websockets
import (
"encoding/json"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/utils/logger"
)
// Hub maintains the set of active clients and broadcasts messages to the
@@ -27,6 +26,12 @@ type Hub struct {
unregister chan *Client
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// NewHub returns a new hub configuration
func NewHub() *Hub {
return &Hub{
@@ -49,7 +54,7 @@ func (h *Hub) Run() {
close(client.send)
}
case message := <-h.Broadcast:
logger.Log().Debugf("Message received: %s", message)
// logger.Log().Debugf("[broadcast] %s", message)
for client := range h.Clients {
select {
case client.send <- message:
@@ -68,7 +73,7 @@ func Broadcast(t string, msg interface{}) {
return
}
w := data.WebsocketNotification{}
w := WebsocketNotification{}
w.Type = t
w.Data = msg
b, err := json.Marshal(w)

View File

@@ -1,81 +0,0 @@
package smtpd
import (
"bytes"
"net"
"net/mail"
"regexp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
"github.com/mhale/smtpd"
)
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())
return err
}
if _, err := storage.Store(storage.DefaultMailbox, 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())
}
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtp] received mail from %s for %s with subject %s", from, to[0], subject)
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
return config.SMTPAuth.Match(string(username), string(password)), nil
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPSSLCert != "" {
logger.Log().Info("[smtp] enabling TLS")
}
if config.SMTPAuthFile != "" {
logger.Log().Info("[smtp] enabling authentication")
}
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthFile != "" {
srv.AuthHandler = authHandler
srv.AuthRequired = true
}
if config.SMTPSSLCert != "" {
err := srv.ConfigureTLS(config.SMTPSSLCert, config.SMTPSSLKey)
if err != nil {
return err
}
}
return srv.ListenAndServe()
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,6 @@ import (
"fmt"
"io/ioutil"
"math/rand"
"os"
"path"
"testing"
"time"
@@ -17,117 +15,94 @@ import (
var (
testTextEmail []byte
testMimeEmail []byte
testRuns = 100
)
func TestTextEmailInserts(t *testing.T) {
setup(false)
t.Log("Testing memory storage")
setup()
defer Close()
t.Log("Testing text email storage")
RepeatTest:
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
assertEqual(t, count, 1000, "incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
t.Logf("inserted 1,000 text emails in %s\n", time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
assertEqual(t, count, 0, "incorrect number of text emails deleted")
t.Logf("deleted 1,000 text emails in %s\n", time.Since(delStart))
db.Close()
if config.DataDir == "" {
setup(true)
t.Logf("Testing physical storage to %s", config.DataDir)
defer os.RemoveAll(config.DataDir)
goto RepeatTest
}
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
assertEqualStats(t, 0, 0)
}
func TestMimeEmailInserts(t *testing.T) {
setup(false)
t.Log("Testing memory storage")
setup()
defer Close()
t.Log("Testing mime email storage")
RepeatTest:
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
assertEqual(t, count, 1000, "incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
t.Logf("inserted 1,000 emails with mime attachments in %s\n", time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
assertEqual(t, count, 0, "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
t.Logf("deleted 1,000 mime emails in %s\n", time.Since(delStart))
db.Close()
if config.DataDir == "" {
setup(true)
t.Logf("Testing physical storage to %s", config.DataDir)
defer os.RemoveAll(config.DataDir)
goto RepeatTest
}
assertEqualStats(t, 0, 0)
}
func TestRetrieveMimeEmail(t *testing.T) {
setup(false)
t.Log("Testing memory storage")
setup()
defer Close()
RepeatTest:
id, err := Store(DefaultMailbox, testMimeEmail)
t.Log("Testing mime email retrieval")
id, err := Store(testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(DefaultMailbox, id)
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -143,27 +118,28 @@ RepeatTest:
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(DefaultMailbox, id, msg.Attachments[0].PartID)
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(DefaultMailbox, id, msg.Inline[0].PartID)
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
db.Close()
if config.DataDir == "" {
setup(true)
t.Logf("Testing physical storage to %s", config.DataDir)
defer os.RemoveAll(config.DataDir)
goto RepeatTest
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
}
func TestSearch(t *testing.T) {
setup(false)
t.Log("Testing memory storage")
setup()
defer Close()
RepeatTest:
for i := 0; i < 1000; i++ {
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
@@ -183,13 +159,13 @@ RepeatTest:
t.Fail()
}
if _, err := Store(DefaultMailbox, buf.Bytes()); err != nil {
if _, err := Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 101; i++ {
for i := 1; i < 51; i++ {
// search a random something that will return a single result
searchIndx := rand.Intn(4) + 1
var search string
@@ -199,12 +175,12 @@ RepeatTest:
case 2:
search = fmt.Sprintf("to-%d@example.com", i)
case 3:
search = fmt.Sprintf("Subject line %d end", i)
search = fmt.Sprintf("\"Subject line %d end\"", i)
default:
search = fmt.Sprintf("the email body %d jdsauk dwqmdqw", i)
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
}
summaries, err := Search(DefaultMailbox, search, 0, 200)
summaries, err := Search(search, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -220,57 +196,43 @@ RepeatTest:
}
// search something that will return 200 rsults
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 200)
summaries, err := Search("This is the email body", 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 200, "200 search results expected")
db.Close()
if config.DataDir == "" {
setup(true)
t.Logf("Testing physical storage to %s", config.DataDir)
defer os.RemoveAll(config.DataDir)
goto RepeatTest
}
assertEqual(t, len(summaries), testRuns, "search results expected")
}
func BenchmarkImportText(b *testing.B) {
setup(false)
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
if _, err := Store(testTextEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func BenchmarkImportMime(b *testing.B) {
setup(false)
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
if _, err := Store(testMimeEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func setup(dataDir bool) {
func setup() {
config.NoLogging = true
config.MaxMessages = 0
if dataDir {
config.DataDir = fmt.Sprintf("%s-%d", path.Join(os.TempDir(), "mailpit-tests"), time.Now().UnixNano())
} else {
config.DataDir = ""
}
config.DataFile = ""
if err := InitDB(); err != nil {
panic(err)
@@ -296,3 +258,14 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if total != s.Total {
t.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
}
if unread != s.Unread {
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
}
}

133
storage/search.go Normal file
View File

@@ -0,0 +1,133 @@
package storage
import (
"regexp"
"strings"
"github.com/leporo/sqlf"
)
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string, start, limit int) *sqlf.Stmt {
if limit == 0 {
limit = 50
}
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
`).
OrderBy("Sort DESC").
Limit(limit).
Offset(start)
if limit > 0 {
q = q.Limit(limit)
}
for _, w := range args {
if cleanString(w) == "" {
continue
}
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
continue
}
if strings.HasPrefix(w, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "cc:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "bcc:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "subject:") {
w = cleanString(w[8:])
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "tag:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%")
} else {
q.Where("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if w == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else {
// search text
if exclude {
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}
return q
}

View File

@@ -1,115 +0,0 @@
package storage
import (
"sync"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
"github.com/ostafen/clover/v2"
)
var (
mailboxStats = map[string]data.MailboxStats{}
statsLock = sync.RWMutex{}
)
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet(mailbox string) data.MailboxStats {
mailbox = sanitizeMailboxName(mailbox)
statsLock.Lock()
defer statsLock.Unlock()
s, ok := mailboxStats[mailbox]
if !ok {
return data.MailboxStats{
Total: 0,
Unread: 0,
}
}
return s
}
// Refresh will completely refresh the existing stats for a given mailbox
func statsRefresh(mailbox string) error {
logger.Log().Debugf("[stats] refreshing stats for %s", mailbox)
total, err := db.Count(clover.NewQuery(mailbox))
if err != nil {
return err
}
unread, err := db.Count(clover.NewQuery(mailbox).Where(clover.Field("Read").IsFalse()))
if err != nil {
return nil
}
statsLock.Lock()
mailboxStats[mailbox] = data.MailboxStats{
Total: total,
Unread: unread,
}
statsLock.Unlock()
return nil
}
func statsAddNewMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total + 1,
Unread: s.Unread + 1,
}
}
statsLock.Unlock()
}
// Delete one message from the totals. If the message was unread,
// then it will also deduct one from the Unread status.
func statsDeleteOneMessage(mailbox string, unread bool) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
// deduct from the totals
if s.Total > 0 {
s.Total = s.Total - 1
}
// only deduct if the original was unread
if unread && s.Unread > 0 {
s.Unread = s.Unread - 1
}
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total,
Unread: s.Unread,
}
}
statsLock.Unlock()
}
// Mark one message as read
func statsReadOneMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total,
Unread: s.Unread - 1,
}
}
statsLock.Unlock()
}
// Mark one message as unread
func statsUnreadOneMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total,
Unread: s.Unread + 1,
}
}
statsLock.Unlock()
}

110
storage/structs.go Normal file
View File

@@ -0,0 +1,110 @@
package storage
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message data excluding physical attachments
//
// swagger:model Message
type Message struct {
// Unique message database id
ID string
// Read status
Read bool
// From address
From *mail.Address
// To addresses
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// ReplyTo addresses
ReplyTo []*mail.Address
// Message subject
Subject string
// Message date if set, else date received
Date time.Time
// Message tags
Tags []string
// Message body text
Text string
// Message body HTML
HTML string
// Message size in bytes
Size int
// Inline message attachments
Inline []Attachment
// Message attachments
Attachments []Attachment
}
// Attachment struct for inline and attachments
//
// swagger:model Attachment
type Attachment struct {
// attachment part id
PartID string
// file name
FileName string
// content type
ContentType string
// content id
ContentID string
// size in bytes
Size int
}
// MessageSummary struct for frontend messages
//
// swagger:model MessageSummary
type MessageSummary struct {
// Unique message database id
ID string
// Read status
Read bool
// From address
From *mail.Address
// To address
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// Email subject
Subject string
// Created time
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size int
// Whether the message has any attachments
Attachments int
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
Tags []string
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
return o
}

86
storage/tags.go Normal file
View File

@@ -0,0 +1,86 @@
package storage
import (
"context"
"encoding/json"
"regexp"
"sort"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/leporo/sqlf"
)
// SetTags will set the tags for a given message ID, used via API
func SetTags(id string, tags []string) error {
applyTags := []string{}
reg := regexp.MustCompile(`\s+`)
for _, t := range tags {
t = strings.TrimSpace(reg.ReplaceAllString(t, " "))
if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
tagJSON, err := json.Marshal(applyTags)
if err != nil {
logger.Log().Errorf("[db] setting tags for message %s", id)
return err
}
_, err = sqlf.Update("mailbox").
Set("Tags", string(tagJSON)).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id)
}
return err
}
// Used to auto-apply tags to new messages
func findTags(message *[]byte) []string {
tags := []string{}
if len(config.SMTPTags) == 0 {
return tags
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) {
tags = append(tags, t.Tag)
}
}
sort.Strings(tags)
return tags
}
// Get message tags from the database for a given message ID.
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
var data string
q := sqlf.From("mailbox").
Select(`Tags`).To(&data).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
logger.Log().Error(err)
return tags
}
if err := json.Unmarshal([]byte(data), &tags); err != nil {
logger.Log().Error(err)
return tags
}
return tags
}

View File

@@ -1,22 +1,28 @@
package storage
import (
"context"
"database/sql"
"net/mail"
"os"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/ostafen/clover/v2"
"github.com/leporo/sqlf"
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
data, _ := env.AddressList(key)
data, err := env.AddressList(key)
if err != nil || data == nil {
return []*mail.Address{}
}
return data
}
@@ -31,7 +37,12 @@ func createSearchText(env *enmime.Envelope) string {
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
h := strings.TrimSpace(html2text.HTML2Text(env.HTML))
h := strings.TrimSpace(
html2text.HTML2TextWithOptions(
env.HTML,
html2text.WithLinksInnerText(),
),
)
if h != "" {
b.WriteString(h + " ")
} else {
@@ -47,56 +58,127 @@ func createSearchText(env *enmime.Envelope) string {
return d
}
// cleanString removed unwanted characters from stored search text and search queries
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|:|\,|;)`)
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;)`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
}
// Auto-prune runs every 5 minutes to automatically delete oldest messages
// Auto-prune runs every minute to automatically delete oldest messages
// if total is greater than the threshold
func pruneCron() {
func dbCron() {
for {
// time.Sleep(5 * 60 * time.Second)
time.Sleep(60 * time.Second)
mailboxes, err := db.ListCollections()
if err != nil {
logger.Log().Errorf("[db] %s", err)
start := time.Now()
// check if database contains deleted data and has not beein in use
// for 5 minutes, if so VACUUM
currentTime := time.Now()
diff := currentTime.Sub(dbLastAction)
if dbDataDeleted && diff.Minutes() > 5 {
dbDataDeleted = false
_, err := db.Exec("VACUUM")
if err == nil {
elapsed := time.Since(start)
logger.Log().Debugf("[db] compressed idle database in %s", elapsed)
}
continue
}
for _, m := range mailboxes {
total, _ := db.Count(clover.NewQuery(m))
if total > config.MaxMessages {
limit := total - config.MaxMessages
if limit > 5000 {
limit = 5000
if config.MaxMessages > 0 {
q := sqlf.Select("ID").
From("mailbox").
OrderBy("Sort DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
if err := q.Query(nil, db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
start := time.Now()
if err := db.Delete(clover.NewQuery(m).
Sort(clover.SortOption{Field: "Created", Direction: 1}).
Limit(limit)); err != nil {
logger.Log().Warnf("Error pruning %s: %s", m, err.Error())
continue
}
elapsed := time.Since(start)
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
_ = statsRefresh(m)
if !strings.HasSuffix(m, "_data") {
websockets.Broadcast("prune", nil)
ids = append(ids, id)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
if len(ids) == 0 {
continue
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
err = tx.Commit()
if err != nil {
logger.Log().Errorf(err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf(err.Error())
}
}
dbDataDeleted = true
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
websockets.Broadcast("prune", nil)
}
}
}
// SanitizeMailboxName returns a clean mailbox name
// allowing only `alphanumeric` characters and `-``
func sanitizeMailboxName(mailbox string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
// IsFile returns whether a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return re.ReplaceAllString(mailbox, "")
return true
}
func inArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
return true
}
}
return false
}
// escPercentChar replaces `%` with `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}

View File

@@ -19,9 +19,13 @@ func Log() *logrus.Logger {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
}
if config.NoLogging {
} else if config.QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if config.NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"runtime"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/semver"
)
@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
// get the running binary
oldExec, err := os.Executable()
if err != nil {
panic(err)
return "", err
}
if err = replaceFile(oldExec, newExec); err != nil {