mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 02:37:02 +00:00
Compare commits
471 Commits
v1.21.0
...
ffb3067680
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffb3067680 | ||
|
|
dc3e7e701f | ||
|
|
f1d0bcda90 | ||
|
|
4f651e4f14 | ||
|
|
c3819ca26d | ||
|
|
4febeb1acd | ||
|
|
10ad4df8cc | ||
|
|
632113fcc5 | ||
|
|
08ed46fc46 | ||
|
|
6927c2b73b | ||
|
|
ac81da5ae0 | ||
|
|
f1d55e4e39 | ||
|
|
b622252411 | ||
|
|
5527379475 | ||
|
|
1d87f1164e | ||
|
|
b4ca68eb48 | ||
|
|
971ae95a67 | ||
|
|
c8caa29e24 | ||
|
|
7d314d2b50 | ||
|
|
9d2f30787a | ||
|
|
b9d071db81 | ||
|
|
a5ee550ba3 | ||
|
|
3e41beb214 | ||
|
|
43b8ba3dc6 | ||
|
|
d41eca3df7 | ||
|
|
e6fd638067 | ||
|
|
e2b1b2d0fe | ||
|
|
9b4ec97483 | ||
|
|
e735904167 | ||
|
|
94113222cc | ||
|
|
5414695508 | ||
|
|
dd74d46880 | ||
|
|
0bfbb4cc5f | ||
|
|
38c0c4fd47 | ||
|
|
9391b075d0 | ||
|
|
a87b2a9455 | ||
|
|
8d18618e4a | ||
|
|
a63bcd9bd3 | ||
|
|
f33f9bec2d | ||
|
|
ff47ba96b8 | ||
|
|
b9f36312d7 | ||
|
|
291c449591 | ||
|
|
d7a4a60536 | ||
|
|
464ff68c34 | ||
|
|
9383c5876b | ||
|
|
a3616e52d9 | ||
|
|
980e54c21f | ||
|
|
eac491cd89 | ||
|
|
12076bca72 | ||
|
|
028ca1d715 | ||
|
|
7d7ba88e9c | ||
|
|
973fc1f975 | ||
|
|
1679a0aba5 | ||
|
|
4a4c149eed | ||
|
|
c01335f0e3 | ||
|
|
181cb0714a | ||
|
|
00d52d5931 | ||
|
|
050da038af | ||
|
|
36cc06c125 | ||
|
|
2734efbc66 | ||
|
|
7cda4a36f1 | ||
|
|
45b3676e52 | ||
|
|
d50347d667 | ||
|
|
c035139b54 | ||
|
|
3108d82e06 | ||
|
|
648d5863da | ||
|
|
585ea1dc30 | ||
|
|
c66a06379a | ||
|
|
c5c9292863 | ||
|
|
6f1f4f34c9 | ||
|
|
877a9159ce | ||
|
|
c4582889ad | ||
|
|
cd1cf695b9 | ||
|
|
392904fd23 | ||
|
|
f0160c0e29 | ||
|
|
f9024d1f77 | ||
|
|
061f159293 | ||
|
|
e69a0d75c9 | ||
|
|
0847167694 | ||
|
|
6dd3587ec6 | ||
|
|
2d1e38d4fd | ||
|
|
153174f928 | ||
|
|
3b9b470c09 | ||
|
|
dd99a4bcf0 | ||
|
|
5bf2f2796b | ||
|
|
a469655f65 | ||
|
|
432fedeafa | ||
|
|
37e4ff4139 | ||
|
|
2808316dd2 | ||
|
|
43d759b0fc | ||
|
|
264222d599 | ||
|
|
5e4bdb78b8 | ||
|
|
fc9572156b | ||
|
|
d52a0d550f | ||
|
|
fcce621f18 | ||
|
|
f4cd19aac2 | ||
|
|
46ccf866b2 | ||
|
|
266611fda0 | ||
|
|
fe3920e3c6 | ||
|
|
ac02802d62 | ||
|
|
7d6aab4e01 | ||
|
|
36d8525557 | ||
|
|
0f0a5d942f | ||
|
|
b987006897 | ||
|
|
c8e0bee8bb | ||
|
|
3b0ae24c2a | ||
|
|
aca491f10c | ||
|
|
6724f0ccdd | ||
|
|
93088f3361 | ||
|
|
e817bf5f7d | ||
|
|
4d100a9ec3 | ||
|
|
958fa6cf1a | ||
|
|
27e12474f5 | ||
|
|
302b269fb6 | ||
|
|
2d9157ffd3 | ||
|
|
242c96244a | ||
|
|
d308e7f30b | ||
|
|
85a9cc3c2b | ||
|
|
f94ce556e5 | ||
|
|
5ad8619893 | ||
|
|
8d692b6bd9 | ||
|
|
99ab38fbba | ||
|
|
2cf040e787 | ||
|
|
cde80bf0fd | ||
|
|
1a41d433c6 | ||
|
|
49557e8e59 | ||
|
|
339f6ef31d | ||
|
|
2e187cfcef | ||
|
|
39ecefa108 | ||
|
|
ae65312d02 | ||
|
|
0770bd8d19 | ||
|
|
e2314fb3b9 | ||
|
|
1dd0bf3d29 | ||
|
|
568ad5da62 | ||
|
|
d3063ea248 | ||
|
|
6ccc294a1e | ||
|
|
5629f39d40 | ||
|
|
314f30bee5 | ||
|
|
5c8931c04d | ||
|
|
ca6357f262 | ||
|
|
3645219615 | ||
|
|
7d74516270 | ||
|
|
fb1a06bc86 | ||
|
|
7809a26374 | ||
|
|
120f078a32 | ||
|
|
776912d38a | ||
|
|
e3b37943a8 | ||
|
|
5054d98701 | ||
|
|
8ce6fc0db5 | ||
|
|
933d064a51 | ||
|
|
ad26ca34e5 | ||
|
|
531801f934 | ||
|
|
0faa71310e | ||
|
|
646e93c58b | ||
|
|
854844924f | ||
|
|
343db8bb61 | ||
|
|
781d8d2332 | ||
|
|
618d1f77b5 | ||
|
|
5577b748af | ||
|
|
4619d9be88 | ||
|
|
6051952a9b | ||
|
|
a54697b3de | ||
|
|
f6bb0d1ffd | ||
|
|
41ef4ecd60 | ||
|
|
39d80df809 | ||
|
|
be95839838 | ||
|
|
8f187fe275 | ||
|
|
bb2793354a | ||
|
|
42aa38ddeb | ||
|
|
7423b9660b | ||
|
|
0b7503261c | ||
|
|
e43be79968 | ||
|
|
70855a50c5 | ||
|
|
0b21a3aba2 | ||
|
|
507217844b | ||
|
|
5a4d13b15a | ||
|
|
fbc1dc6118 | ||
|
|
2a7aa33a0a | ||
|
|
45f07d3c9b | ||
|
|
894220dc44 | ||
|
|
cce21854b9 | ||
|
|
33fe814c34 | ||
|
|
df75064009 | ||
|
|
e1ed21abff | ||
|
|
f3e3536cdb | ||
|
|
38c343867e | ||
|
|
75504c7bba | ||
|
|
79323df3bd | ||
|
|
5a672df0fc | ||
|
|
72730ba470 | ||
|
|
76d9a410b8 | ||
|
|
7ca84d3b0d | ||
|
|
d430e38aad | ||
|
|
2d1fb7cf14 | ||
|
|
8634c9e8f2 | ||
|
|
e74237e71c | ||
|
|
1f1eed8a8b | ||
|
|
643a7ed9d5 | ||
|
|
b6f7833805 | ||
|
|
b612ac948c | ||
|
|
7b805ef7cd | ||
|
|
7c7d915059 | ||
|
|
79e9439858 | ||
|
|
cc5991c038 | ||
|
|
e29883fa1c | ||
|
|
f99d9ecf69 | ||
|
|
429d2e2b3a | ||
|
|
91f0515b48 | ||
|
|
88e1aa324b | ||
|
|
a7e27ea9b7 | ||
|
|
796749e1a1 | ||
|
|
91e4a87995 | ||
|
|
3ad7623e84 | ||
|
|
f4954ba115 | ||
|
|
4195f30d95 | ||
|
|
690e82cbfd | ||
|
|
2d42c87285 | ||
|
|
c208d71a33 | ||
|
|
3cacede2d7 | ||
|
|
1886277b6e | ||
|
|
3fff79e29f | ||
|
|
7dee371721 | ||
|
|
95e3ef6fca | ||
|
|
f88a42fda4 | ||
|
|
3aae06ff6b | ||
|
|
4b5ce0afed | ||
|
|
d4ee6fd987 | ||
|
|
29f4a10d89 | ||
|
|
8f7bf25022 | ||
|
|
a1110e5ad8 | ||
|
|
31a2ed8824 | ||
|
|
f675ef7b5e | ||
|
|
4257a89584 | ||
|
|
52957cd81f | ||
|
|
1520143c45 | ||
|
|
5107ce0191 | ||
|
|
40afef8ffd | ||
|
|
fed20de522 | ||
|
|
6999b2ea02 | ||
|
|
72e92d2d1e | ||
|
|
803adf29ac | ||
|
|
fb0230a460 | ||
|
|
873193bcec | ||
|
|
e3538cb86a | ||
|
|
e6ab9e1008 | ||
|
|
86f3546bfe | ||
|
|
a6b5f5f76b | ||
|
|
82d7bdc971 | ||
|
|
020d5b0fcb | ||
|
|
f2b91ac9d5 | ||
|
|
4dff7adc1d | ||
|
|
9bfdeb5f7b | ||
|
|
c5b3edf87d | ||
|
|
8c59229f97 | ||
|
|
56739ceac2 | ||
|
|
5240b1b33e | ||
|
|
8f80a57c3c | ||
|
|
04ea905619 | ||
|
|
b84b428434 | ||
|
|
91409310d7 | ||
|
|
99a3e17243 | ||
|
|
ff272d1c5e | ||
|
|
74c6a0a434 | ||
|
|
e16267ab50 | ||
|
|
8d86b39385 | ||
|
|
38914348a5 | ||
|
|
25580b9a68 | ||
|
|
a1c2690c44 | ||
|
|
ff8b6326ab | ||
|
|
5d2966d726 | ||
|
|
bf5609a39b | ||
|
|
4ed5011a8f | ||
|
|
68d911431f | ||
|
|
d0716b4995 | ||
|
|
84a519e84d | ||
|
|
e1a6904eca | ||
|
|
bc200c663f | ||
|
|
009f3a8fd9 | ||
|
|
cfe695c35d | ||
|
|
5eb77cbb18 | ||
|
|
5ab8486a6c | ||
|
|
8691afd850 | ||
|
|
3517ec42c9 | ||
|
|
9dada2fd30 | ||
|
|
84a7d8b30d | ||
|
|
a50d80b5fc | ||
|
|
5e1a228328 | ||
|
|
b4f4b857f3 | ||
|
|
658c94a2d1 | ||
|
|
05375fed7a | ||
|
|
5961bf000d | ||
|
|
87c67e1b1f | ||
|
|
c79abb3e5f | ||
|
|
8ce5a35e3b | ||
|
|
dd0ba8b09d | ||
|
|
f18b8f8fb1 | ||
|
|
c76adb8c01 | ||
|
|
1b95f2fe39 | ||
|
|
1400936760 | ||
|
|
04289091bc | ||
|
|
6acbbb4446 | ||
|
|
bc9a5cd4c2 | ||
|
|
85a2c1502a | ||
|
|
15de95ff62 | ||
|
|
019613004d | ||
|
|
c204339dbb | ||
|
|
981ccd2a74 | ||
|
|
20b2eb22d4 | ||
|
|
6c0ef5ba33 | ||
|
|
2dbc4ea601 | ||
|
|
54b6d8f85c | ||
|
|
5e84633e76 | ||
|
|
7fbff71689 | ||
|
|
164e7c150d | ||
|
|
d87e3087f3 | ||
|
|
56ca3afbad | ||
|
|
b7fa68dff9 | ||
|
|
5214739618 | ||
|
|
2bb2036380 | ||
|
|
de693c9c68 | ||
|
|
bb5ea68f03 | ||
|
|
b4131dbeae | ||
|
|
e3e1d734b6 | ||
|
|
25671ba94f | ||
|
|
290ffdd80c | ||
|
|
753591105a | ||
|
|
5d0bbe74e0 | ||
|
|
ff1751350f | ||
|
|
fdd3cb3074 | ||
|
|
4f81fb417f | ||
|
|
39886cf57c | ||
|
|
9a1f3a6bb5 | ||
|
|
ac9b7de295 | ||
|
|
d4406cf02b | ||
|
|
577461bff4 | ||
|
|
289466bdb8 | ||
|
|
3c2e227d32 | ||
|
|
7dfdf54e97 | ||
|
|
f61a390bd9 | ||
|
|
b827d75c3e | ||
|
|
784e3de8a1 | ||
|
|
876d0eb5da | ||
|
|
6e9760d5d9 | ||
|
|
aafd2a20d9 | ||
|
|
284e66f0ba | ||
|
|
8995cddfa5 | ||
|
|
8401ffff22 | ||
|
|
a6d0db174b | ||
|
|
c7d7810e68 | ||
|
|
d26e317d25 | ||
|
|
a051fd49a9 | ||
|
|
f836e92d58 | ||
|
|
1db502ef4e | ||
|
|
703e981a8b | ||
|
|
8878ece19f | ||
|
|
7c366669c7 | ||
|
|
61a1ed0e49 | ||
|
|
9b2e90279d | ||
|
|
a1d35d488d | ||
|
|
a3bd62482d | ||
|
|
d0458e2e7a | ||
|
|
f40f95555a | ||
|
|
a5558d97ce | ||
|
|
50c072ef4f | ||
|
|
561032f367 | ||
|
|
8f1b7b6ec0 | ||
|
|
be94385f38 | ||
|
|
61306e1ae4 | ||
|
|
dac9fcf735 | ||
|
|
3528bc8da7 | ||
|
|
cb3300212f | ||
|
|
f377414c3b | ||
|
|
a2db203a08 | ||
|
|
b1eb58c9c8 | ||
|
|
76b7e74049 | ||
|
|
ed0caa0081 | ||
|
|
45e67b5cac | ||
|
|
0c63c29769 | ||
|
|
f4d6dd5c39 | ||
|
|
496bf17db7 | ||
|
|
86b5524217 | ||
|
|
cba9f0043c | ||
|
|
a1b08ea2bc | ||
|
|
3d6d899a6d | ||
|
|
9687329fc1 | ||
|
|
04410ff463 | ||
|
|
a29b969e61 | ||
|
|
8425780ccd | ||
|
|
8331e11f7f | ||
|
|
d7df895261 | ||
|
|
e2fab49873 | ||
|
|
a95bc3d29f | ||
|
|
f278933bb9 | ||
|
|
4d86297169 | ||
|
|
2a6ab0476b | ||
|
|
b2ffb7476d | ||
|
|
338f205234 | ||
|
|
168049faf9 | ||
|
|
2a1a5ae852 | ||
|
|
e30754a167 | ||
|
|
fd46d4076b | ||
|
|
7703d09919 | ||
|
|
b3e7995342 | ||
|
|
c8937e218f | ||
|
|
82cfd605e5 | ||
|
|
d67feec713 | ||
|
|
9f4908d11d | ||
|
|
13027bf10b | ||
|
|
37c0558ddd | ||
|
|
9d205cfdcc | ||
|
|
e01a0f8f4b | ||
|
|
ebf8f3568b | ||
|
|
572bda80a2 | ||
|
|
23fee8e4e1 | ||
|
|
b2f4acb7ed | ||
|
|
2ea92d1b7e | ||
|
|
0af5d184f5 | ||
|
|
4ad6a4553c | ||
|
|
b5734691e8 | ||
|
|
e78bc79f5e | ||
|
|
0fbb9463d4 | ||
|
|
4c954e655c | ||
|
|
16fbb728a4 | ||
|
|
b27a28cbf5 | ||
|
|
b1c745fb32 | ||
|
|
ccd35c7dc9 | ||
|
|
11a9014241 | ||
|
|
93c9eb3fbf | ||
|
|
68f2a3189e | ||
|
|
d57aa9b37e | ||
|
|
14f1a44c7a | ||
|
|
3e7d4f8175 | ||
|
|
22cae16e00 | ||
|
|
6e44691f6d | ||
|
|
aabb2acab9 | ||
|
|
0277f4e944 | ||
|
|
7c31b3d0c0 | ||
|
|
c7f3937cb2 | ||
|
|
5a3448accf | ||
|
|
53b55ec320 | ||
|
|
0ff73b7df6 | ||
|
|
5666462f29 | ||
|
|
bc23e6336d | ||
|
|
6d115ceb86 | ||
|
|
a33d0c9d07 | ||
|
|
f08a959545 | ||
|
|
249a02b71a | ||
|
|
b698e037bf | ||
|
|
8dab8abde4 | ||
|
|
8c2e5d856a | ||
|
|
1afd138cc5 | ||
|
|
c4e0e651a3 | ||
|
|
0b11ce26ab | ||
|
|
bc7d7f901d | ||
|
|
a7fac05209 | ||
|
|
657cada916 | ||
|
|
ea219e5ec9 | ||
|
|
8f79fcd0d5 | ||
|
|
61cff513cb | ||
|
|
13caeb4f5b | ||
|
|
3f2457cc6a | ||
|
|
2c94c32722 | ||
|
|
d448211653 | ||
|
|
ccef1ae20d | ||
|
|
0f24496ee2 | ||
|
|
2743e2e0cb | ||
|
|
4be92633f2 | ||
|
|
5675abef84 | ||
|
|
bd47c19058 | ||
|
|
47c6062b1c | ||
|
|
48a1f6b877 |
@@ -1,47 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
{{ if .Versions -}}
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
## [Unreleased]
|
||||
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
{{ range .Unreleased.CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
## [{{ .Tag.Name }}]
|
||||
|
||||
{{ if .NoteGroups -}}
|
||||
{{ range .NoteGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Notes }}
|
||||
{{ .Body }}
|
||||
{{ end -}}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{- if .MergeCommits -}}
|
||||
### Pull Requests
|
||||
{{ range .MergeCommits -}}
|
||||
- {{ .Header }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
@@ -1,12 +0,0 @@
|
||||
{{ if .Versions -}}
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
@@ -1,46 +0,0 @@
|
||||
style: github
|
||||
template: CHANGELOG.tpl.md
|
||||
info:
|
||||
title: CHANGELOG
|
||||
repository_url: https://github.com/axllent/mailpit
|
||||
options:
|
||||
commits:
|
||||
# filters:
|
||||
# Type:
|
||||
# - feat
|
||||
# - fix
|
||||
# - perf
|
||||
# - refactor
|
||||
commit_groups:
|
||||
title_maps:
|
||||
feature: Feature
|
||||
fix: Fix
|
||||
# perf: Performance Improvements
|
||||
# refactor: Code Refactoring
|
||||
sort_by: Custom
|
||||
title_order:
|
||||
- Feature
|
||||
- Chore
|
||||
- UI
|
||||
- API
|
||||
- Libs
|
||||
- Docker
|
||||
- Security
|
||||
- Fix
|
||||
- Bugfix
|
||||
- Docs
|
||||
- Swagger
|
||||
- Build
|
||||
- Testing
|
||||
- Test
|
||||
- Tests
|
||||
- Pull Requests
|
||||
header:
|
||||
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
|
||||
pattern_maps:
|
||||
- Type
|
||||
- Scope
|
||||
- Subject
|
||||
notes:
|
||||
keywords:
|
||||
- BREAKING CHANGE
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [axllent]
|
||||
thanks_dev: u/gh/axllent
|
||||
|
||||
21
.github/SECURITY.md
vendored
Normal file
21
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
If you believe you have found a security vulnerability, you can report it using one of the following methods:
|
||||
|
||||
1. **GitHub Security Advisory (Recommended):** Use the "Report a vulnerability" button in the [Security tab](../../security/advisories/new) of this repository.
|
||||
2. **Email:** Send your findings to security@axllent.org
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will be kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
49
.github/cliff.toml
vendored
Normal file
49
.github/cliff.toml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
## https://git-cliff.org/
|
||||
[changelog]
|
||||
body = """
|
||||
{% if version %}\
|
||||
\n## [{{ version }}]
|
||||
{% else %}\
|
||||
\n## Unreleased
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}\
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = ""
|
||||
header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file."
|
||||
postprocessors = [
|
||||
{pattern = "reponse", replace = "response"},
|
||||
{pattern = "messsage", replace = "message"},
|
||||
{pattern = '(?i) go modules', replace = " Go dependencies"},
|
||||
{pattern = '(?i) node modules', replace = " node dependencies"},
|
||||
{pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"},
|
||||
]
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# HTML comments added for grouping order, stripped on generation
|
||||
commit_parsers = [
|
||||
{body = ".*security", group = "<!-- 1 -->Security"},
|
||||
{message = "(?i)^security", group = "<!-- 1 -->Security"},
|
||||
{message = "(?i)^feat", group = "<!-- 2 -->Feature"},
|
||||
{message = "(?i)^chore", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^libs", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^ui", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^api", group = "<!-- 4 -->API"},
|
||||
{message = "(?i)^fix", group = "<!-- 5 -->Fix"},
|
||||
{message = "(?i)^doc", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^swagger", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^test", group = "<!-- 7 -->Test"},
|
||||
]
|
||||
|
||||
# Exclude commits that are not matched by any commit parser.
|
||||
# filter_commits = true
|
||||
# Order releases topologically instead of chronologically.
|
||||
# topo_order = true
|
||||
# Order of commits in each group/release within the changelog.
|
||||
# Allowed values: newest, oldest
|
||||
sort_commits = "oldest"
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -8,16 +8,16 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
|
||||
2
.github/workflows/build-docker-edge.yml
vendored
2
.github/workflows/build-docker-edge.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
4
.github/workflows/build-docker.yml
vendored
4
.github/workflows/build-docker.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -48,6 +48,6 @@ jobs:
|
||||
axllent/mailpit:latest
|
||||
axllent/mailpit:${{ github.ref_name }}
|
||||
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
3
.github/workflows/close-stale-issues.yml
vendored
3
.github/workflows/close-stale-issues.yml
vendored
@@ -10,12 +10,13 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9.0.0
|
||||
- uses: actions/stale@v10.1.1
|
||||
with:
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
exempt-issue-labels: "enhancement,bug,awaiting feedback"
|
||||
stale-issue-label: "stale"
|
||||
close-issue-reason: "completed"
|
||||
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
|
||||
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
6
.github/workflows/release-build.yml
vendored
6
.github/workflows/release-build.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
- run: echo "Building assets for ${{ github.ref_name }}"
|
||||
- run: npm install
|
||||
|
||||
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Tests (rqlite)
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
|
||||
jobs:
|
||||
test-rqlite:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
rqlite:
|
||||
image: rqlite/rqlite:latest
|
||||
ports:
|
||||
- 4001:4001
|
||||
env:
|
||||
# the HTTP address the rqlite node should advertise
|
||||
HTTP_ADV_ADDR: "localhost:4001"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache-dependency-path: "**/*.sum"
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
env:
|
||||
# set Mailpit to use the rqlite service container
|
||||
MP_DATABASE: "http://localhost:4001"
|
||||
48
.github/workflows/tests.yml
vendored
48
.github/workflows/tests.yml
vendored
@@ -8,17 +8,17 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.21.x]
|
||||
go-version: [stable]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: false
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Go tests
|
||||
uses: actions/cache@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Go environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -26,24 +26,36 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test -p 1 ./internal/storage ./server ./server/pop3 ./internal/tools ./internal/html2text ./internal/linkcheck -v
|
||||
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
- name: Test Go linting (gofmt)
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
# https://olegk.dev/github-actions-and-go
|
||||
run: gofmt -s -w . && git diff --exit-code
|
||||
- name: Run Go tests
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
- name: Run Go benchmarking
|
||||
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
- name: Build web UI
|
||||
- name: Set up node environment
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
- name: Install JavaScript dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm install
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
- name: Run JavaScript linting
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run lint
|
||||
- name: Test JavaScript packaging
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run package
|
||||
|
||||
# validate the swagger file
|
||||
- name: Validate OpenAPI definition
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: swaggerexpert/swagger-editor-validate@v1
|
||||
with:
|
||||
definition-file: server/ui/api/v1/swagger.json
|
||||
# # validate the swagger file
|
||||
# - name: Validate OpenAPI definition
|
||||
# if: startsWith(matrix.os, 'ubuntu') == true
|
||||
# uses: swaggerexpert/swagger-editor-validate@v1
|
||||
# with:
|
||||
# definition-file: server/ui/api/v1/swagger.json
|
||||
# default-timeout: 20000
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Not within the scope of Prettier
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.json
|
||||
**/*.md
|
||||
**/*.css
|
||||
**/*.html
|
||||
**/*.scss
|
||||
composer.lock
|
||||
40
.vscode/settings.json
vendored
Normal file
40
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": [
|
||||
"AUTHCRAMMD",
|
||||
"AUTHLOGIN",
|
||||
"AUTHPLAIN",
|
||||
"bordercolor",
|
||||
"CRAMMD",
|
||||
"dateparse",
|
||||
"EHLO",
|
||||
"ESMTP",
|
||||
"EXPN",
|
||||
"gofmt",
|
||||
"Healthz",
|
||||
"HTTPIP",
|
||||
"Inlines",
|
||||
"jhillyerd",
|
||||
"leporo",
|
||||
"lithammer",
|
||||
"livez",
|
||||
"Mechs",
|
||||
"navhtml",
|
||||
"neostandard",
|
||||
"nolint",
|
||||
"popperjs",
|
||||
"readyz",
|
||||
"RSET",
|
||||
"shortuuid",
|
||||
"SMTPTLS",
|
||||
"swaggerexpert",
|
||||
"UITLS",
|
||||
"VRFY",
|
||||
"writef"
|
||||
]
|
||||
}
|
||||
1357
CHANGELOG.md
1357
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
22
CONTRIBUTING.md
Normal file
22
CONTRIBUTING.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Contributing to Mailpit
|
||||
|
||||
Thank you for your interest in contributing to Mailpit!
|
||||
|
||||
## Reporting issues and feature requests
|
||||
|
||||
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Please **do not** report security issues here (see below).
|
||||
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Please do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.
|
||||
|
||||
|
||||
## Contributing code
|
||||
|
||||
Please ensure your code is clean and well-commented, and [passes linting](https://mailpit.axllent.org/docs/development/code-linting/) before submitting a Pull Request. Contributions should enhance the functionality or usability of Mailpit, focusing on quality over quantity.
|
||||
|
||||
Note that while assistance from AI tools is perfectly acceptable, **"[vibe coded](https://en.wikipedia.org/wiki/Vibe_coding)" pull requests will most likely not be accepted.**
|
||||
We value the unique insights and creativity that individual contributors bring to the project.
|
||||
|
||||
Thank you for your understanding and for contributing to Mailpit!
|
||||
@@ -6,7 +6,7 @@ COPY . /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git npm && \
|
||||
RUN apk upgrade && apk add git npm && \
|
||||
npm install && npm run package && \
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
|
||||
|
||||
@@ -21,10 +21,10 @@ LABEL org.opencontainers.image.title="Mailpit" \
|
||||
|
||||
COPY --from=builder /mailpit /mailpit
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
RUN apk upgrade --no-cache && apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 1110/tcp 8025/tcp
|
||||
|
||||
HEALTHCHECK --interval=15s CMD /mailpit readyz
|
||||
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
|
||||
21
README.md
21
README.md
@@ -46,8 +46,10 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
|
||||
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
|
||||
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
|
||||
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
|
||||
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
|
||||
@@ -66,12 +68,18 @@ Mailpit runs as a single binary and can be installed in different ways:
|
||||
- **FreeBSD**: `pkg install mailpit`
|
||||
|
||||
|
||||
### Install via bash script (Linux & Mac)
|
||||
### Install via 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)
|
||||
```shell
|
||||
sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
You can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:
|
||||
|
||||
```shell
|
||||
sudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
|
||||
@@ -107,3 +115,10 @@ Please refer to [the documentation](https://mailpit.axllent.org/docs/install/tes
|
||||
Mailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port.
|
||||
A common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP.
|
||||
Mailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
For team features, multiple inboxes, and a hosted setup, try
|
||||
<a href="https://mailtrap.io/?ref=mailpit">Mailtrap</a>, our friendly companion.
|
||||
</p>
|
||||
|
||||
19
SECURITY.md
19
SECURITY.md
@@ -1,19 +0,0 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
|
||||
|
||||
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
|
||||
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
36
cmd/dump.go
Normal file
36
cmd/dump.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/dump"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// dumpCmd represents the dump command
|
||||
var dumpCmd = &cobra.Command{
|
||||
Use: "dump <database> <output-dir>",
|
||||
Short: "Dump all messages from a database to a directory",
|
||||
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
|
||||
|
||||
The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a
|
||||
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
|
||||
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if err := dump.Sync(args[0]); err != nil {
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(dumpCmd)
|
||||
|
||||
dumpCmd.Flags().SortFlags = false
|
||||
|
||||
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
|
||||
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
|
||||
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
|
||||
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
}
|
||||
@@ -30,7 +30,7 @@ Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox
|
||||
The --recent flag will only consider files with a modification date within the last X days.`,
|
||||
// Hidden: true,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
@@ -55,7 +55,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
return nil
|
||||
}
|
||||
defer f.Close() // #nosec
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ status 1 if unhealthy.
|
||||
If running within Docker, it should automatically detect environment
|
||||
settings to determine the HTTP bind interface & port.
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
|
||||
proto := "http"
|
||||
if useHTTPS {
|
||||
|
||||
@@ -18,7 +18,7 @@ var reindexCmd = &cobra.Command{
|
||||
If you have several thousand messages in your mailbox, then it is advised to shut down
|
||||
Mailpit while you reindex as this process will likely result in database locking issues.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
config.Database = args[0]
|
||||
config.MaxMessages = 0
|
||||
|
||||
|
||||
120
cmd/root.go
120
cmd/root.go
@@ -9,10 +9,12 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -38,6 +40,14 @@ Documentation:
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start Prometheus metrics if enabled
|
||||
switch prometheus.GetMode() {
|
||||
case "integrated":
|
||||
prometheus.StartUpdater()
|
||||
case "separate":
|
||||
go prometheus.StartSeparateServer()
|
||||
}
|
||||
|
||||
go server.Listen()
|
||||
|
||||
if err := smtpd.Listen(); err != nil {
|
||||
@@ -57,14 +67,6 @@ func Execute() {
|
||||
}
|
||||
}
|
||||
|
||||
// SendmailExecute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func SendmailExecute() {
|
||||
args := []string{"mailpit", "sendmail"}
|
||||
|
||||
rootCmd.Run(sendmailCmd, args)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// hide autocompletion
|
||||
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
||||
@@ -82,12 +84,15 @@ func init() {
|
||||
initConfigFromEnv()
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
|
||||
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
|
||||
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
|
||||
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
@@ -98,10 +103,17 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)")
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
rootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, "allow-internal-http-requests", config.AllowInternalHTTPRequests, "Allow link-checker & screenshots to access internal IP addresses")
|
||||
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
|
||||
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.HideDeleteAllButton, "hide-delete-all-button", config.HideDeleteAllButton, "Hide the \"Delete all\" button in the web UI")
|
||||
|
||||
// Send API
|
||||
rootCmd.Flags().StringVar(&config.SendAPIAuthFile, "send-api-auth-file", config.SendAPIAuthFile, "A password file for Send API authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, "send-api-auth-accept-any", config.SendAPIAuthAcceptAny, "Accept any username and password for the Send API endpoint, including none")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
@@ -115,6 +127,7 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
|
||||
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
|
||||
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPIgnoreRejectedRecipients, "smtp-ignore-rejected-recipients", config.SMTPIgnoreRejectedRecipients, "Ignore rejected SMTP recipients with 2xx response")
|
||||
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
|
||||
|
||||
// SMTP relay
|
||||
@@ -122,6 +135,13 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
|
||||
|
||||
// SMTP forwarding
|
||||
rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
|
||||
|
||||
// Chaos
|
||||
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
|
||||
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
|
||||
|
||||
// POP3 server
|
||||
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
|
||||
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
|
||||
@@ -133,10 +153,15 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
|
||||
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
|
||||
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
|
||||
rootCmd.Flags().BoolVar(&config.TagsUsername, "tags-username", config.TagsUsername, "Auto-tag messages with the authenticated username")
|
||||
|
||||
// Prometheus metrics
|
||||
rootCmd.Flags().StringVar(&config.PrometheusListen, "enable-prometheus", config.PrometheusListen, "Enable Prometheus metrics: true|false|<ip:port> (eg:'0.0.0.0:9090')")
|
||||
|
||||
// Webhook
|
||||
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
|
||||
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
|
||||
rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (default 0)")
|
||||
|
||||
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
|
||||
@@ -173,6 +198,14 @@ func initConfigFromEnv() {
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
}
|
||||
|
||||
config.DisableWAL = getEnabledFromEnv("MP_DISABLE_WAL")
|
||||
|
||||
config.DisableVersionCheck = getEnabledFromEnv("MP_DISABLE_VERSION_CHECK")
|
||||
|
||||
if len(os.Getenv("MP_COMPRESSION")) > 0 {
|
||||
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
|
||||
}
|
||||
|
||||
config.TenantID = os.Getenv("MP_TENANT_ID")
|
||||
|
||||
config.Label = os.Getenv("MP_LABEL")
|
||||
@@ -208,7 +241,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
@@ -218,12 +251,30 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") {
|
||||
config.AllowInternalHTTPRequests = true
|
||||
}
|
||||
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
|
||||
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
|
||||
}
|
||||
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||
config.AllowUntrustedTLS = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTTP_COMPRESSION") {
|
||||
config.DisableHTTPCompression = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_HIDE_DELETE_ALL_BUTTON") {
|
||||
config.HideDeleteAllButton = true
|
||||
}
|
||||
|
||||
// Send API
|
||||
config.SendAPIAuthFile = os.Getenv("MP_SEND_API_AUTH_FILE")
|
||||
if err := auth.SetSendAPIAuth(os.Getenv("MP_SEND_API_AUTH")); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SEND_API_AUTH_ACCEPT_ANY") {
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
@@ -231,7 +282,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
@@ -256,6 +307,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
|
||||
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_IGNORE_REJECTED_RECIPIENTS") {
|
||||
config.SMTPIgnoreRejectedRecipients = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
|
||||
smtpd.DisableReverseDNS = true
|
||||
}
|
||||
@@ -272,14 +326,41 @@ func initConfigFromEnv() {
|
||||
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
|
||||
}
|
||||
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
|
||||
config.SMTPRelayConfig.TLS = getEnabledFromEnv("MP_SMTP_RELAY_TLS")
|
||||
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
|
||||
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
|
||||
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
|
||||
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
|
||||
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
|
||||
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
|
||||
config.SMTPRelayConfig.OverrideFrom = os.Getenv("MP_SMTP_RELAY_OVERRIDE_FROM")
|
||||
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv("MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS")
|
||||
config.SMTPRelayConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_RELAY_FWD_SMTP_ERRORS")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
|
||||
config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
|
||||
if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
|
||||
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
|
||||
}
|
||||
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
|
||||
config.SMTPForwardConfig.TLS = getEnabledFromEnv("MP_SMTP_FORWARD_TLS")
|
||||
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
|
||||
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
|
||||
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
|
||||
config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
|
||||
config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
|
||||
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
|
||||
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
|
||||
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
|
||||
config.SMTPForwardConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_FORWARD_FWD_SMTP_ERRORS")
|
||||
|
||||
// Chaos
|
||||
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
|
||||
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
|
||||
|
||||
// POP3 server
|
||||
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
|
||||
@@ -287,7 +368,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
|
||||
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
|
||||
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
|
||||
@@ -297,6 +378,12 @@ func initConfigFromEnv() {
|
||||
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
|
||||
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
|
||||
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
|
||||
config.TagsUsername = getEnabledFromEnv("MP_TAGS_USERNAME")
|
||||
|
||||
// Prometheus metrics
|
||||
if len(os.Getenv("MP_ENABLE_PROMETHEUS")) > 0 {
|
||||
config.PrometheusListen = os.Getenv("MP_ENABLE_PROMETHEUS")
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
@@ -305,6 +392,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
|
||||
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
|
||||
}
|
||||
if len(os.Getenv("MP_WEBHOOK_DELAY")) > 0 {
|
||||
webhook.Delay, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_DELAY"))
|
||||
}
|
||||
|
||||
// Demo mode
|
||||
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")
|
||||
@@ -314,9 +404,9 @@ func initConfigFromEnv() {
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
logger.Log().Warn("ENV MP_DATA_FILE has been deprecated, use MP_DATABASE")
|
||||
config.Database = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
|
||||
@@ -12,13 +12,13 @@ var sendmailCmd = &cobra.Command{
|
||||
Use: "sendmail [flags] [recipients]",
|
||||
Short: "A sendmail command replacement for Mailpit",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
sendmail.Run()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(sendmailCmd)
|
||||
var ignored string
|
||||
|
||||
// print out manual help screen
|
||||
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
|
||||
@@ -27,10 +27,13 @@ func init() {
|
||||
// multi-letter single-dash variables (-bs)
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "ignored-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "ignored-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-t", "t", false, "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-name", "F", "", "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-bits", "B", "", "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-errors", "e", "", "Ignored")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -15,29 +14,44 @@ var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Display the current version & update information",
|
||||
Long: `Display the current version & update information (if available).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
updater.AllowPrereleases = true
|
||||
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
update, _ := cmd.Flags().GetBool("update")
|
||||
noReleaseCheck, _ := cmd.Flags().GetBool("no-release-check")
|
||||
|
||||
if update {
|
||||
return updateApp()
|
||||
// Update the application
|
||||
rel, err := config.GHRUConfig.SelfUpdate()
|
||||
if err != nil {
|
||||
fmt.Printf("Error updating: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel.Tag)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
||||
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, config.Version) {
|
||||
if !noReleaseCheck {
|
||||
release, err := config.GHRUConfig.Latest()
|
||||
if err != nil {
|
||||
fmt.Printf("Error checking for latest release: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// The latest version is the same version
|
||||
if release.Tag == config.Version {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// A newer release is available
|
||||
fmt.Printf(
|
||||
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
||||
latest,
|
||||
release.Tag,
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,14 +60,6 @@ func init() {
|
||||
|
||||
versionCmd.Flags().
|
||||
BoolP("update", "u", false, "update to latest version")
|
||||
}
|
||||
|
||||
func updateApp() error {
|
||||
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
|
||||
return nil
|
||||
versionCmd.Flags().
|
||||
Bool("no-release-check", false, "do not check online for the latest release version")
|
||||
}
|
||||
|
||||
422
config/config.go
422
config/config.go
@@ -5,22 +5,34 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/ghru/v2"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/snakeoil"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is the Mailpit version, updated with every release
|
||||
Version = "dev"
|
||||
|
||||
// GHRUConfig is the configuration for the GitHub Release Updater
|
||||
// used to check for updates and self-update
|
||||
GHRUConfig = ghru.Config{
|
||||
Repo: "axllent/mailpit",
|
||||
ArchiveName: "mailpit-{{.OS}}-{{.Arch}}",
|
||||
BinaryName: "mailpit",
|
||||
CurrentVersion: Version,
|
||||
}
|
||||
|
||||
// SMTPListen to listen on <interface>:<port>
|
||||
SMTPListen = "[::]:1025"
|
||||
|
||||
@@ -30,6 +42,14 @@ var (
|
||||
// Database for mail (optional)
|
||||
Database string
|
||||
|
||||
// DisableWAL will disable Write-Ahead Logging in SQLite
|
||||
// @see https://sqlite.org/wal.html
|
||||
DisableWAL bool
|
||||
|
||||
// Compression is the compression level used to store raw messages in the database:
|
||||
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
|
||||
Compression = 1
|
||||
|
||||
// TenantID is an optional prefix to be applied to all database tables,
|
||||
// allowing multiple isolated instances of Mailpit to share a database.
|
||||
TenantID string
|
||||
@@ -63,6 +83,15 @@ var (
|
||||
// Webroot to define the base path for the UI and API
|
||||
Webroot = "/"
|
||||
|
||||
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
|
||||
DisableHTTPCompression bool
|
||||
|
||||
// SendAPIAuthFile for Send API authentication
|
||||
SendAPIAuthFile string
|
||||
|
||||
// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none
|
||||
SendAPIAuthAcceptAny bool
|
||||
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
@@ -98,11 +127,15 @@ var (
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// AllowInternalHTTPRequests will allow HTTP requests to internal IP addresses (e.g., loopback, private, link-local, or multicast) when set to true.
|
||||
// This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons.
|
||||
AllowInternalHTTPRequests = false
|
||||
|
||||
// CLITagsArg is used to map the CLI args
|
||||
CLITagsArg string
|
||||
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.@]){1,100}$`)
|
||||
|
||||
// TagsConfig is a yaml file to pre-load tags
|
||||
TagsConfig string
|
||||
@@ -114,22 +147,15 @@ var (
|
||||
// including x-tags & plus-addresses
|
||||
TagsDisable string
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
|
||||
// TagsUsername enables auto-tagging messages with the authenticated username
|
||||
TagsUsername bool
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
|
||||
SMTPRelayConfig SMTPRelayConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
@@ -143,6 +169,25 @@ var (
|
||||
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
|
||||
SMTPRelayMatchingRegexp *regexp.Regexp
|
||||
|
||||
// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfigFile string
|
||||
|
||||
// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfig SMTPForwardConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// SMTPIgnoreRejectedRecipients if true, will accept emails to rejected recipients with 2xx response but silently drop them
|
||||
SMTPIgnoreRejectedRecipients bool
|
||||
|
||||
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
|
||||
POP3Listen = "[::]:1110"
|
||||
|
||||
@@ -158,6 +203,9 @@ var (
|
||||
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||
EnableSpamAssassin string
|
||||
|
||||
// HideDeleteAllButton hides the delete all button in the web UI
|
||||
HideDeleteAllButton bool
|
||||
|
||||
// WebhookURL for calling
|
||||
WebhookURL string
|
||||
|
||||
@@ -167,18 +215,19 @@ var (
|
||||
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
|
||||
AllowUntrustedTLS bool
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
// PrometheusListen address for Prometheus metrics server
|
||||
// Empty = disabled, true= use existing web server, address = separate server
|
||||
PrometheusListen string
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
// ChaosTriggers are parsed and set in the chaos module
|
||||
ChaosTriggers string
|
||||
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// DisableVersionCheck disables version checking
|
||||
DisableVersionCheck bool
|
||||
|
||||
// DemoMode disables SMTP relay, link checking & HTTP send functionality
|
||||
DemoMode = false
|
||||
)
|
||||
@@ -191,24 +240,45 @@ type autoTag struct {
|
||||
|
||||
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
|
||||
type SMTPRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
|
||||
PreserveMessageIDs bool `yaml:"preserve-message-ids"` // preserve the original Message-ID when relaying
|
||||
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
}
|
||||
|
||||
// SMTPForwardConfigStruct struct for parsing yaml & storing variables
|
||||
type SMTPForwardConfigStruct struct {
|
||||
To string `yaml:"to"` // comma-separated list of email addresses
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
cssFontRestriction := "*"
|
||||
@@ -219,7 +289,8 @@ func VerifyConfig() error {
|
||||
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
|
||||
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
|
||||
// See server.middleWareFunc()
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
ContentSecurityPolicy = fmt.Sprintf(
|
||||
"default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
|
||||
@@ -227,6 +298,10 @@ func VerifyConfig() error {
|
||||
Database = filepath.Join(Database, "mailpit.db")
|
||||
}
|
||||
|
||||
if Compression < 0 || Compression > 3 {
|
||||
return errors.New("[db] compression level must be between 0 and 3")
|
||||
}
|
||||
|
||||
Label = tools.Normalize(Label)
|
||||
|
||||
if err := parseMaxAge(); err != nil {
|
||||
@@ -246,6 +321,7 @@ func VerifyConfig() error {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
// Web UI & API
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
@@ -268,8 +344,19 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if UITLSCert != "" {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
if strings.HasPrefix(UITLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
UITLSCert = snakeoil.Public(UITLSCert)
|
||||
} else {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(UITLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
UITLSKey = snakeoil.Private(UITLSKey)
|
||||
} else {
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
}
|
||||
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
|
||||
@@ -280,13 +367,67 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Send API
|
||||
if SendAPIAuthFile != "" {
|
||||
SendAPIAuthFile = filepath.Clean(SendAPIAuthFile)
|
||||
|
||||
if !isFile(SendAPIAuthFile) {
|
||||
return fmt.Errorf("[send-api] password file not found or readable: %s", SendAPIAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SendAPIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetSendAPIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Info("[send-api] enabling basic authentication")
|
||||
}
|
||||
|
||||
if auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {
|
||||
return errors.New("[send-api] authentication cannot use both credentials and --send-api-auth-accept-any")
|
||||
}
|
||||
|
||||
if SendAPIAuthAcceptAny && auth.UICredentials != nil {
|
||||
logger.Log().Info("[send-api] disabling authentication")
|
||||
}
|
||||
|
||||
// Prometheus configuration validation
|
||||
if PrometheusListen != "" {
|
||||
mode := strings.ToLower(strings.TrimSpace(PrometheusListen))
|
||||
if mode != "true" && mode != "false" {
|
||||
// Validate as address for separate server mode
|
||||
_, err := net.ResolveTCPAddr("tcp", PrometheusListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[prometheus] %s", err.Error())
|
||||
}
|
||||
} else if mode == "true" {
|
||||
logger.Log().Info("[prometheus] enabling metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
|
||||
return errors.New("[smtp] you must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
if strings.HasPrefix(SMTPTLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
SMTPTLSCert = snakeoil.Public(SMTPTLSCert)
|
||||
} else {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(SMTPTLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
SMTPTLSKey = snakeoil.Private(SMTPTLSKey)
|
||||
} else {
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
}
|
||||
|
||||
if !isFile(SMTPTLSCert) {
|
||||
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
|
||||
@@ -344,10 +485,28 @@ func VerifyConfig() error {
|
||||
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
if err := parseChaosTriggers(); err != nil {
|
||||
return fmt.Errorf("[chaos] %s", err.Error())
|
||||
}
|
||||
|
||||
if chaos.Enabled {
|
||||
logger.Log().Info("[chaos] is enabled")
|
||||
}
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
if strings.HasPrefix(POP3TLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
POP3TLSCert = snakeoil.Public(POP3TLSCert)
|
||||
} else {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
}
|
||||
if strings.HasPrefix(POP3TLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
POP3TLSKey = snakeoil.Private(POP3TLSKey)
|
||||
} else {
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
}
|
||||
|
||||
if !isFile(POP3TLSCert) {
|
||||
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
|
||||
@@ -358,7 +517,7 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
|
||||
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
|
||||
return errors.New("[pop3] you must provide both a POP3 TLS certificate and a key")
|
||||
}
|
||||
if POP3Listen != "" {
|
||||
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
|
||||
@@ -432,6 +591,14 @@ func VerifyConfig() error {
|
||||
logger.Log().Infof("[smtp] only allowing recipients matching regexp: %s", SMTPAllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPIgnoreRejectedRecipients {
|
||||
if SMTPAllowedRecipientsRegexp == nil {
|
||||
logger.Log().Warn("[smtp] ignoring rejected recipients has no effect without setting smtp-allowed-recipients")
|
||||
} else {
|
||||
logger.Log().Info("[smtp] ignoring rejected recipients")
|
||||
}
|
||||
}
|
||||
|
||||
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -455,8 +622,10 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
SMTPRelayMatchingRegexp = re
|
||||
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
|
||||
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
logger.Log().Infof(
|
||||
"[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
|
||||
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +634,15 @@ func VerifyConfig() error {
|
||||
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// separate forwarding config validation to account for environment variables
|
||||
if err := validateForwardConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if DemoMode {
|
||||
MaxMessages = 1000
|
||||
// this deserves a warning
|
||||
@@ -473,171 +651,3 @@ func VerifyConfig() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[smtp] relay configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("[smtp] relay host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
|
||||
if SMTPRelayConfig.AllowedRecipients == "" {
|
||||
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPRelayConfig (if Host is set)
|
||||
func validateRelayConfig() error {
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
51
config/utils.go
Normal file
51
config/utils.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
290
config/validators.go
Normal file
290
config/validators.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[relay] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("[relay] host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
|
||||
if SMTPRelayConfig.AllowedRecipients == "" {
|
||||
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPRelayConfig (if Host is set)
|
||||
func validateRelayConfig() error {
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[relay] host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[relay] host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("[relay] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
SMTPRelayConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.STARTTLS && SMTPRelayConfig.TLS {
|
||||
return fmt.Errorf("[relay] TLS & STARTTLS cannot be required together")
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPForwardConfigFile (if set)
|
||||
func parseForwardConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[forward] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return errors.New("[forward] host not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPForwardConfig (if Host is set)
|
||||
func validateForwardConfig() error {
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Port == 0 {
|
||||
SMTPForwardConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
|
||||
|
||||
if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
|
||||
SMTPForwardConfig.Auth = "none"
|
||||
} else if SMTPForwardConfig.Auth == "plain" {
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPForwardConfig.Auth == "login" {
|
||||
SMTPForwardConfig.Auth = "login"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
|
||||
SMTPForwardConfig.Auth = "cram-md5"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
|
||||
return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.To == "" {
|
||||
return errors.New("[forward] To addresses missing")
|
||||
}
|
||||
|
||||
to := []string{}
|
||||
addresses := strings.Split(SMTPForwardConfig.To, ",")
|
||||
for _, a := range addresses {
|
||||
a = strings.TrimSpace(a)
|
||||
m, err := mail.ParseAddress(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
|
||||
}
|
||||
to = append(to, m.Address)
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return errors.New("[forward] no valid To addresses found")
|
||||
}
|
||||
|
||||
// overwrite the To field with the cleaned up list
|
||||
SMTPForwardConfig.To = strings.Join(to, ",")
|
||||
|
||||
if SMTPForwardConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
SMTPForwardConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.STARTTLS && SMTPForwardConfig.TLS {
|
||||
return fmt.Errorf("[forward] TLS & STARTTLS cannot be required together")
|
||||
}
|
||||
|
||||
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseChaosTriggers() error {
|
||||
if ChaosTriggers == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
|
||||
|
||||
parts := strings.Split(ChaosTriggers, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if !re.MatchString(p) {
|
||||
return fmt.Errorf("invalid argument: %s", p)
|
||||
}
|
||||
|
||||
matches := re.FindAllStringSubmatch(p, 1)
|
||||
key := matches[0][1]
|
||||
errorCode, err := strconv.Atoi(matches[0][2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probability, err := strconv.Atoi(matches[0][3])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := chaos.Set(key, errorCode, probability); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +1,39 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import pluginVue from 'esbuild-plugin-vue-next'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
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 doWatch = process.env.WATCH === "true";
|
||||
const doMinify = process.env.MINIFY === "true";
|
||||
|
||||
const ctx = await esbuild.context(
|
||||
{
|
||||
entryPoints: [
|
||||
"server/ui-src/app.js",
|
||||
"server/ui-src/docs.js"
|
||||
],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
}
|
||||
)
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: "true",
|
||||
__VUE_PROD_DEVTOOLS__: "false",
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ["import"],
|
||||
quietDeps: true,
|
||||
}),
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
if (doWatch) {
|
||||
await ctx.watch()
|
||||
await ctx.watch();
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
ctx.dispose()
|
||||
await ctx.rebuild();
|
||||
ctx.dispose();
|
||||
}
|
||||
|
||||
76
eslint.config.js
Normal file
76
eslint.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import globals from "globals";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import vue from "eslint-plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));
|
||||
|
||||
export default [
|
||||
/* Use .gitignore to prevent linting of irrelevant files */
|
||||
includeIgnoreFile(gitignorePath, ".gitignore"),
|
||||
|
||||
/* ESLint's recommended rules */
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: js.configs.recommended.rules,
|
||||
},
|
||||
|
||||
/* Vue-specific rules */
|
||||
...vue.configs["flat/recommended"],
|
||||
|
||||
/* Prettier is responsible for formatting, so we disable conflicting rules */
|
||||
eslintConfigPrettier,
|
||||
|
||||
/* Our custom rules */
|
||||
{
|
||||
rules: {
|
||||
/* Always use arrow functions for tidiness and consistency */
|
||||
"prefer-arrow-callback": "error",
|
||||
|
||||
/* Always use camelCase for variable names */
|
||||
camelcase: [
|
||||
"error",
|
||||
{
|
||||
ignoreDestructuring: false,
|
||||
ignoreGlobals: true,
|
||||
ignoreImports: false,
|
||||
properties: "never",
|
||||
},
|
||||
],
|
||||
|
||||
/* The default case in switch statements must always be last */
|
||||
"default-case-last": "error",
|
||||
|
||||
/* Always use dot notation where possible (e.g. `obj.val` over `obj['val']`) */
|
||||
"dot-notation": "error",
|
||||
|
||||
/* Always use `===` and `!==` for comparisons unless unambiguous */
|
||||
eqeqeq: ["error", "smart"],
|
||||
|
||||
/* Never use `eval()` as it violates our CSP and can lead to security issues */
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
|
||||
/* Prevents accidental use of template literals in plain strings, e.g. "my ${var}" */
|
||||
"no-template-curly-in-string": "error",
|
||||
|
||||
/* Avoid unnecessary ternary operators */
|
||||
"no-unneeded-ternary": "error",
|
||||
|
||||
/* Avoid unused expressions that have no purpose */
|
||||
"no-unused-expressions": "error",
|
||||
|
||||
/* Always use `const` or `let` to make scope behaviour clear */
|
||||
"no-var": "error",
|
||||
|
||||
/* Always use shorthand syntax for objects where possible, e.g. { a, b() { } } */
|
||||
"object-shorthand": "error",
|
||||
|
||||
/* Always use `const` for variables that are never reassigned */
|
||||
"prefer-const": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
96
go.mod
96
go.mod
@@ -1,67 +1,81 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.23
|
||||
|
||||
toolchain go1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8
|
||||
github.com/axllent/ghru/v2 v2.1.0
|
||||
github.com/axllent/semver v1.0.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/kovidgoyal/imaging v1.6.3
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.2
|
||||
github.com/vanng822/go-premailer v1.22.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
golang.org/x/time v0.7.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.31.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inbucket/html2text v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.7 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.3 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/image v0.21.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
277
go.sum
277
go.sum
@@ -1,34 +1,49 @@
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/axllent/ghru/v2 v2.1.0 h1:zNW96KO+rmXggizZhHzIX7MExOiV4jx+63Y9nXlwLV0=
|
||||
github.com/axllent/ghru/v2 v2.1.0/go.mod h1:8l7s1phdc375vvf8LHxT7wnJqXlThdHJR5EBtHNWhTg=
|
||||
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
|
||||
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 h1:4txT5G2kqVAKMjzidIabL/8KqjIK71yj30YOeuxLn10=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -39,160 +54,172 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
|
||||
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
|
||||
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=
|
||||
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
|
||||
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
|
||||
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_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d h1:c88ius/WcN19inn14R+X2EQCFjjAu92txgdxNNnGxDI=
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
|
||||
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
|
||||
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
|
||||
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.22.0 h1:5gG92q3nG3BwcfUUDzrSDbYDbpwYC/lri4nba+vhdJQ=
|
||||
github.com/vanng822/go-premailer v1.22.0/go.mod h1:K7DxRBW6AxdZUTqmW9jU6041CtfAWiP9uSXm2WmMB1k=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/vanng822/go-premailer v1.31.0 h1:r1a1WH2I5NnGMhrmjVZyYhY0ThvaamKBkS2UuM91Fuo=
|
||||
github.com/vanng822/go-premailer v1.31.0/go.mod h1:hzI26/YvzUADrxqifxGLJvNvn3tWBU6VMHRvxsskpuo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -201,51 +228,53 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
258
install.sh
258
install.sh
@@ -1,98 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
GH_REPO="axllent/mailpit"
|
||||
TIMEOUT=90
|
||||
# This script will install the latest release of Mailpit.
|
||||
|
||||
set -e
|
||||
# Check dependencies is installed
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "Then $cmd command is required but not installed."
|
||||
echo "Please install $cmd and try again."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
;;
|
||||
# Check if the OS is supported.
|
||||
OS=
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
echo "OS not supported."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# detect the arch
|
||||
OS_type="$(uname -m)"
|
||||
case "$OS_type" in
|
||||
# Detect the architecture of the OS.
|
||||
OS_ARCH=
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64)
|
||||
OS_type='amd64'
|
||||
OS_ARCH="amd64"
|
||||
;;
|
||||
i?86 | x86)
|
||||
OS_type='386'
|
||||
OS_ARCH="386"
|
||||
;;
|
||||
aarch64 | arm64)
|
||||
OS_type='arm64'
|
||||
OS_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo 'OS type not supported'
|
||||
echo "OS architecture not supported."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
|
||||
GH_REPO="axllent/mailpit"
|
||||
INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin}"
|
||||
TIMEOUT=90
|
||||
# This is used to authenticate with the GitHub API. (Fix the public rate limiting issue)
|
||||
# Try the GITHUB_TOKEN environment variable is set globally.
|
||||
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
|
||||
|
||||
#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"
|
||||
# Update the default values if the user has set.
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--install-path)
|
||||
shift
|
||||
case "$1" in
|
||||
*/*)
|
||||
# Remove trailing slashes from the path.
|
||||
INSTALL_PATH="$(echo "$1" | sed 's#/\+$##')"
|
||||
[ -z "$INSTALL_PATH" ] && INSTALL_PATH="/"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
--auth | --auth-token | --github-token | --token)
|
||||
shift
|
||||
case "$1" in
|
||||
gh*)
|
||||
GITHUB_API_TOKEN="$1"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
echo "Downloading Mailpit $VERSION"
|
||||
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
|
||||
# Description of the sort parameters for curl command.
|
||||
# -s: Silent mode.
|
||||
# -f: Fail silently on server errors.
|
||||
# -L: Follow redirects.
|
||||
# -m: Set maximum time allowed for the transfer.
|
||||
|
||||
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
|
||||
echo "Error downloading"
|
||||
exit 2
|
||||
}
|
||||
if [ -n "$GITHUB_API_TOKEN" ] && [ "${#GITHUB_API_TOKEN}" -gt 36 ]; then
|
||||
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT -H "Authorization: Bearer $GITHUB_API_TOKEN" https://api.github.com/repos/${GH_REPO}/releases/latest)"
|
||||
EXIT_CODE=$?
|
||||
else
|
||||
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT https://api.github.com/repos/${GH_REPO}/releases/latest)"
|
||||
EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
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
|
||||
;;
|
||||
VERSION=""
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
# Extracts the latest version using jq, awk, or sed.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Use jq -n because the output is not a valid JSON in sh.
|
||||
VERSION=$(jq -n "$CURL_OUTPUT" | jq -r '.tag_name')
|
||||
elif command -v awk >/dev/null 2>&1; then
|
||||
VERSION=$(echo "$CURL_OUTPUT" | awk -F: '$1 ~ /tag_name/ {gsub(/[^v0-9\.]+/, "", $2) ;print $2; exit}')
|
||||
elif command -v sed >/dev/null 2>&1; then
|
||||
VERSION=$(echo "$CURL_OUTPUT" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
|
||||
else
|
||||
EXIT_CODE=3
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate the version.
|
||||
case "$VERSION" in
|
||||
v[0-9][0-9\.]*) ;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
echo "There was an error trying to check what is the latest version of Mailpit."
|
||||
echo "Please try again later."
|
||||
exit $EXIT_CODE
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
echo "Installed successfully to /usr/local/bin/mailpit"
|
||||
TEMP_DIR="$(mktemp -qd)"
|
||||
EXIT_CODE=$?
|
||||
# Ensure the temporary directory exists and is a directory.
|
||||
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
|
||||
echo "ERROR: Creating temporary directory."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
GH_REPO_BIN="mailpit-${OS}-${OS_ARCH}.tar.gz"
|
||||
if [ "$INSTALL_PATH" = "/" ]; then
|
||||
INSTALL_BIN_PATH="/mailpit"
|
||||
else
|
||||
INSTALL_BIN_PATH="${INSTALL_PATH}/mailpit"
|
||||
fi
|
||||
cd "$TEMP_DIR" || EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
# Download the latest release.
|
||||
#
|
||||
# Description of the sort parameters for curl command.
|
||||
# -s: Silent mode.
|
||||
# -f: Fail silently on server errors.
|
||||
# -L: Follow redirects.
|
||||
# -m: Set maximum time allowed for the transfer.
|
||||
# -o: Write output to a file instead of stdout.
|
||||
curl -sfL -m $TIMEOUT -o "${GH_REPO_BIN}" "https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
|
||||
EXIT_CODE=$?
|
||||
|
||||
# The following conditions check each step of the installation.
|
||||
# If there is an error in any of the steps, an error message is printed.
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
if ! [ -f "${GH_REPO_BIN}" ]; then
|
||||
EXIT_CODE=1
|
||||
echo "ERROR: Downloading latest release."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
tar zxf "$GH_REPO_BIN"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Extracting \"${GH_REPO_BIN}\"."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ] && [ ! -d "$INSTALL_PATH" ]; then
|
||||
mkdir -p "${INSTALL_PATH}"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Creating \"${INSTALL_PATH}\" directory."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
cp mailpit "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Copying mailpit to \"${INSTALL_PATH}\" directory."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
chmod 755 "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Setting permissions for \"$INSTALL_BIN_PATH\" binary."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set the owner and group to root:root if the script is run as root.
|
||||
if [ $EXIT_CODE -eq 0 ] && [ "$(id -u)" -eq "0" ]; then
|
||||
OWNER="root"
|
||||
GROUP="root"
|
||||
# Set the OWNER, GROUP variable when the OS not use the default root:root.
|
||||
case "$OS" in
|
||||
darwin) GROUP="wheel" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
chown "${OWNER}:${GROUP}" "$INSTALL_BIN_PATH"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "ERROR: Setting ownership for \"$INSTALL_BIN_PATH\" binary."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Changing to temporary directory."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
# Cleanup the temporary directory.
|
||||
rm -rf "$TEMP_DIR"
|
||||
# Check the EXIT_CODE variable, and print the success or error message.
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
echo "There was an error installing Mailpit."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
echo "Installed successfully to \"$INSTALL_BIN_PATH\"."
|
||||
exit 0
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
var (
|
||||
// UICredentials passwords
|
||||
UICredentials *htpasswd.File
|
||||
// SendAPICredentials passwords
|
||||
SendAPICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
// POP3Credentials passwords
|
||||
@@ -36,6 +38,25 @@ func SetUIAuth(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSendAPIAuth will set Send API credentials
|
||||
func SetSendAPIAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSMTPAuth will set SMTP credentials
|
||||
func SetSMTPAuth(s string) error {
|
||||
var err error
|
||||
|
||||
163
internal/dump/dump.go
Normal file
163
internal/dump/dump.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package dump is used to export all messages from mailpit into a directory
|
||||
package dump
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
)
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
outDir string
|
||||
|
||||
// Base URL of mailpit instance
|
||||
base string
|
||||
|
||||
// URL is the base URL of a remove Mailpit instance
|
||||
URL string
|
||||
|
||||
summary = []storage.MessageSummary{}
|
||||
)
|
||||
|
||||
// Sync will sync all messages from the specified database or API to the specified output directory
|
||||
func Sync(d string) error {
|
||||
|
||||
outDir = path.Clean(d)
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
return errors.New("invalid URL")
|
||||
}
|
||||
|
||||
base = strings.TrimRight(URL, "/") + "/"
|
||||
}
|
||||
|
||||
if base == "" && config.Database == "" {
|
||||
return errors.New("no database or API URL specified")
|
||||
}
|
||||
|
||||
if !tools.IsDir(outDir) {
|
||||
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := loadIDs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := saveMessages(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadIDs will load all message IDs from the specified database or API
|
||||
func loadIDs() error {
|
||||
if base != "" {
|
||||
// remote
|
||||
logger.Log().Debugf("Fetching messages summary from %s", base)
|
||||
res, err := http.Get(base + "api/v1/messages?limit=0")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data apiv1.MessagesSummary
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary = data.Messages
|
||||
|
||||
} else {
|
||||
// make sure the database isn't pruned while open
|
||||
config.MaxMessages = 0
|
||||
|
||||
var err error
|
||||
// local database
|
||||
if err = storage.InitDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
|
||||
|
||||
summary, err = storage.List(0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
return errors.New("no messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveMessages() error {
|
||||
for _, m := range summary {
|
||||
out := path.Join(outDir, m.ID+".eml")
|
||||
|
||||
// skip if message exists
|
||||
if tools.IsFile(out) {
|
||||
continue
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
if base != "" {
|
||||
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
b, err = io.ReadAll(res.Body)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
b, err = storage.GetMessageRaw(m.ID)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
|
||||
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.Chtimes(out, m.Created, m.Created)
|
||||
|
||||
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,167 +38,178 @@ var htmlTests = map[string]string{
|
||||
|
||||
// Image tests using regex to match against img[src]
|
||||
var imageRegexpTests = map[string]*regexp.Regexp{
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`),
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`),
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`),
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`),
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`),
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`),
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`),
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`),
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`),
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`),
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`),
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`),
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`),
|
||||
}
|
||||
|
||||
var cssInlineTests = map[string]string{
|
||||
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
|
||||
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
|
||||
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
|
||||
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
|
||||
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
|
||||
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
|
||||
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
|
||||
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
|
||||
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
|
||||
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
|
||||
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
|
||||
"css-background": "[style*=\"background:\"], [background]", // 57.407406
|
||||
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
|
||||
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
|
||||
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
|
||||
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
|
||||
"css-border": "[style*=\"border:\"], [border]", // 86.95652
|
||||
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
|
||||
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
|
||||
"css-caption-side": "[style*=\"caption-side:\"]", // 84
|
||||
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
|
||||
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
|
||||
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
|
||||
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
|
||||
"css-direction": "[style*=\"direction:\"]", // 97.77778
|
||||
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
|
||||
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
|
||||
"css-display-none": "[style*=\"display:none\"]", // 84.78261
|
||||
"css-display": "[style*=\"display:\"]", // 55.555553
|
||||
"css-filter": "[style*=\"filter:\"]", // 50
|
||||
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
|
||||
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
|
||||
"css-float": "[style*=\"float:\"]", // 85.10638
|
||||
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
|
||||
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
|
||||
"css-font": "[style*=\"font:\"]", // 95.833336
|
||||
"css-gap": "[style*=\"gap:\"]", // 40
|
||||
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
|
||||
"css-height": "[style*=\"height:\"], [height]", // 77.08333
|
||||
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
|
||||
"css-important": "[style*=\"!important\"]", // 43.478264
|
||||
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
|
||||
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
|
||||
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
|
||||
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
|
||||
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
|
||||
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
|
||||
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
|
||||
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
|
||||
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
|
||||
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
|
||||
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
|
||||
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
|
||||
"css-margin": "[style*=\"margin:\"]", // 71.42857
|
||||
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
|
||||
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
|
||||
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
|
||||
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
|
||||
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
|
||||
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
|
||||
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
|
||||
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
|
||||
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
|
||||
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
|
||||
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
|
||||
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
|
||||
"css-outline": "[style*=\"outline:\"]", // 80.85106
|
||||
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
|
||||
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
|
||||
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
|
||||
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
|
||||
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
|
||||
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
|
||||
"css-position": "[style*=\"position:\"]", // 19.56522
|
||||
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
|
||||
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
|
||||
"css-rgba": "[style*=\"rgba(\"]", // 56
|
||||
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
|
||||
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
|
||||
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
|
||||
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
|
||||
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
|
||||
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
|
||||
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
|
||||
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
|
||||
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
|
||||
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
|
||||
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
|
||||
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
|
||||
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
|
||||
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
|
||||
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
|
||||
"css-transform": "[style*=\"transform:\"]", // 50
|
||||
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
|
||||
"css-variables": "[style*=\"variables:\"]", // 46.551727
|
||||
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
|
||||
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
|
||||
"css-width": "[style*=\"width:\"], [width]", // 87.5
|
||||
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
|
||||
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
|
||||
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
|
||||
// inline attribute <match>=""
|
||||
var styleInlineAttributes = map[string]string{
|
||||
"css-background-color": "[bgcolor]",
|
||||
"css-background": "[background]",
|
||||
"css-border": "[border]",
|
||||
"css-height": "[height]",
|
||||
"css-padding": "[padding]",
|
||||
"css-width": "[width]",
|
||||
}
|
||||
|
||||
// inline style="<match>"
|
||||
var cssInlineRegexTests = map[string]*regexp.Regexp{
|
||||
"css-accent-color": regexp.MustCompile(`(?i)(^|\s|;)accent-color(\s+)?:`),
|
||||
"css-align-items": regexp.MustCompile(`(?i)(^|\s|;)align-items(\s+)?:`),
|
||||
"css-aspect-ratio": regexp.MustCompile(`(?i)(^|\s|;)aspect-ratio(\s+)?:`),
|
||||
"css-background-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)background-blend-mode(\s+)?:`),
|
||||
"css-background-clip": regexp.MustCompile(`(?i)(^|\s|;)background-clip(\s+)?:`),
|
||||
"css-background-color": regexp.MustCompile(`(?i)(^|\s|;)background-color(\s+)?:`),
|
||||
"css-background-image": regexp.MustCompile(`(?i)(^|\s|;)background-image(\s+)?:`),
|
||||
"css-background-origin": regexp.MustCompile(`(?i)(^|\s|;)background-origin(\s+)?:`),
|
||||
"css-background-position": regexp.MustCompile(`(?i)(^|\s|;)background-position(\s+)?:`),
|
||||
"css-background-repeat": regexp.MustCompile(`(?i)(^|\s|;)background-repeat(\s+)?:`),
|
||||
"css-background-size": regexp.MustCompile(`(?i)(^|\s|;)background-size(\s+)?:`),
|
||||
"css-background": regexp.MustCompile(`(?i)(^|\s|;)background(\s+)?:`),
|
||||
"css-block-inline-size": regexp.MustCompile(`(?i)(^|\s|;)block-inline-size(\s+)?:`),
|
||||
"css-border-image": regexp.MustCompile(`(?i)(^|\s|;)border-image(\s+)?:`),
|
||||
"css-border-inline-block-individual": regexp.MustCompile(`(?i)(^|\s|;)border-inline(\s+)?:`),
|
||||
"css-border-radius": regexp.MustCompile(`(?i)(^|\s|;)border-radius(\s+)?:`),
|
||||
"css-border": regexp.MustCompile(`(?i)(^|\s|;)border(\s+)?:`),
|
||||
"css-box-shadow": regexp.MustCompile(`(?i)(^|\s|;)box-shadow(\s+)?:`),
|
||||
"css-box-sizing": regexp.MustCompile(`(?i)(^|\s|;)box-sizing(\s+)?:`),
|
||||
"css-caption-side": regexp.MustCompile(`(?i)(^|\s|;)caption-side(\s+)?:`),
|
||||
"css-clip-path": regexp.MustCompile(`(?i)(^|\s|;)clip-path(\s+)?:`),
|
||||
"css-column-count": regexp.MustCompile(`(?i)(^|\s|;)column-count(\s+)?:`),
|
||||
"css-column-layout-properties": regexp.MustCompile(`(?i)(^|\s|;)column-layout-properties(\s+)?:`),
|
||||
"css-conic-gradient": regexp.MustCompile(`(?i)(^|\s|;)conic-gradient(\s+)?:`),
|
||||
"css-direction": regexp.MustCompile(`(?i)(^|\s|;)direction(\s+)?:`),
|
||||
"css-display-flex": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:(\s+)?flex($|\s|;)`),
|
||||
"css-display-grid": regexp.MustCompile(`(?i)(^|\s|;)display:grid`),
|
||||
"css-display-none": regexp.MustCompile(`(?i)(^|\s|;)display:none`),
|
||||
"css-display": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:`),
|
||||
"css-filter": regexp.MustCompile(`(?i)(^|\s|;)filter(\s+)?:`),
|
||||
"css-flex-direction": regexp.MustCompile(`(?i)(^|\s|;)flex-direction(\s+)?:`),
|
||||
"css-flex-wrap": regexp.MustCompile(`(?i)(^|\s|;)flex-wrap(\s+)?:`),
|
||||
"css-float": regexp.MustCompile(`(?i)(^|\s|;)float(\s+)?:`),
|
||||
"css-font-kerning": regexp.MustCompile(`(?i)(^|\s|;)font-kerning(\s+)?:`),
|
||||
"css-font-weight": regexp.MustCompile(`(?i)(^|\s|;)font-weight(\s+)?:`),
|
||||
"css-font": regexp.MustCompile(`(?i)(^|\s|;)font(\s+)?:`),
|
||||
"css-gap": regexp.MustCompile(`(?i)(^|\s|;)gap(\s+)?:`),
|
||||
"css-grid-template": regexp.MustCompile(`(?i)(^|\s|;)grid-template(\s+)?:`),
|
||||
"css-height": regexp.MustCompile(`(?i)(^|\s|;)height(\s+)?:`),
|
||||
"css-hyphens": regexp.MustCompile(`(?i)(^|\s|;)hyphens(\s+)?:`),
|
||||
"css-important": regexp.MustCompile(`(?i)!important($|\s|;)`),
|
||||
"css-inline-size": regexp.MustCompile(`(?i)(^|\s|;)inline-size(\s+)?:`),
|
||||
"css-intrinsic-size": regexp.MustCompile(`(?i)(^|\s|;)intrinsic-size(\s+)?:`),
|
||||
"css-justify-content": regexp.MustCompile(`(?i)(^|\s|;)justify-content(\s+)?:`),
|
||||
"css-letter-spacing": regexp.MustCompile(`(?i)(^|\s|;)letter-spacing(\s+)?:`),
|
||||
"css-line-height": regexp.MustCompile(`(?i)(^|\s|;)line-height(\s+)?:`),
|
||||
"css-list-style-image": regexp.MustCompile(`(?i)(^|\s|;)list-style-image(\s+)?:`),
|
||||
"css-list-style-position": regexp.MustCompile(`(?i)(^|\s|;)list-style-position(\s+)?:`),
|
||||
"css-list-style": regexp.MustCompile(`(?i)(^|\s|;)list-style(\s+)?:`),
|
||||
"css-margin-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-block-(start|end)(\s+)?:`),
|
||||
"css-margin-inline-block": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-block(\s+)?:`),
|
||||
"css-margin-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-(start|end)(\s+)?:`),
|
||||
"css-margin-inline": regexp.MustCompile(`(?i)(^|\s|;)margin-inline(\s+)?:`),
|
||||
"css-margin": regexp.MustCompile(`(?i)(^|\s|;)margin(\s+)?:`),
|
||||
"css-max-block-size": regexp.MustCompile(`(?i)(^|\s|;)max-block-size(\s+)?:`),
|
||||
"css-max-height": regexp.MustCompile(`(?i)(^|\s|;)max-height(\s+)?:`),
|
||||
"css-max-width": regexp.MustCompile(`(?i)(^|\s|;)max-width(\s+)?:`),
|
||||
"css-min-height": regexp.MustCompile(`(?i)(^|\s|;)min-height(\s+)?:`),
|
||||
"css-min-inline-size": regexp.MustCompile(`(?i)(^|\s|;)min-inline-size(\s+)?:`),
|
||||
"css-min-width": regexp.MustCompile(`(?i)(^|\s|;)min-width(\s+)?:`),
|
||||
"css-mix-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)mix-blend-mode(\s+)?:`),
|
||||
"css-modern-color": regexp.MustCompile(`(?i)(^|\s|;)modern-color(\s+)?:`),
|
||||
"css-object-fit": regexp.MustCompile(`(?i)(^|\s|;)object-fit(\s+)?:`),
|
||||
"css-object-position": regexp.MustCompile(`(?i)(^|\s|;)object-position(\s+)?:`),
|
||||
"css-opacity": regexp.MustCompile(`(?i)(^|\s|;)opacity(\s+)?:`),
|
||||
"css-outline-offset": regexp.MustCompile(`(?i)(^|\s|;)outline-offset(\s+)?:`),
|
||||
"css-outline": regexp.MustCompile(`(?i)(^|\s|;)outline(\s+)?:`),
|
||||
"css-overflow-wrap": regexp.MustCompile(`(?i)(^|\s|;)overflow-wrap(\s+)?:`),
|
||||
"css-overflow": regexp.MustCompile(`(?i)(^|\s|;)overflow(\s+)?:`),
|
||||
"css-padding-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-block-(start|end)(\s+)?:`),
|
||||
"css-padding-inline-block": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-block(\s+)?:`),
|
||||
"css-padding-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-(start|end)(\s+)?:`),
|
||||
"css-padding": regexp.MustCompile(`(?i)(^|\s|;)padding(\s+)?:`),
|
||||
"css-position": regexp.MustCompile(`(?i)(^|\s|;)position(\s+)?:`),
|
||||
"css-radial-gradient": regexp.MustCompile(`(?i)(^|\s|;)radial-gradient(\s+)?:`),
|
||||
"css-rgb": regexp.MustCompile(`(?i)(\s|:)rgb\(`),
|
||||
"css-rgba": regexp.MustCompile(`(?i)(\s|:)rgba\(`),
|
||||
"css-scroll-snap": regexp.MustCompile(`(?i)(^|\s|;)roll-snap(\s+)?:`),
|
||||
"css-tab-size": regexp.MustCompile(`(?i)(^|\s|;)tab-size(\s+)?:`),
|
||||
"css-table-layout": regexp.MustCompile(`(?i)(^|\s|;)table-layout(\s+)?:`),
|
||||
"css-text-align-last": regexp.MustCompile(`(?i)(^|\s|;)text-align-last(\s+)?:`),
|
||||
"css-text-align": regexp.MustCompile(`(?i)(^|\s|;)text-align(\s+)?:`),
|
||||
"css-text-decoration-color": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-color(\s+)?:`),
|
||||
"css-text-decoration-thickness": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-thickness(\s+)?:`),
|
||||
"css-text-decoration": regexp.MustCompile(`(?i)(^|\s|;)text-decoration(\s+)?:`),
|
||||
"css-text-emphasis-position": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis-position(\s+)?:`),
|
||||
"css-text-emphasis": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis(\s+)?:`),
|
||||
"css-text-indent": regexp.MustCompile(`(?i)(^|\s|;)text-indent(\s+)?:`),
|
||||
"css-text-overflow": regexp.MustCompile(`(?i)(^|\s|;)text-overflow(\s+)?:`),
|
||||
"css-text-shadow": regexp.MustCompile(`(?i)(^|\s|;)text-shadow(\s+)?:`),
|
||||
"css-text-transform": regexp.MustCompile(`(?i)(^|\s|;)text-transform(\s+)?:`),
|
||||
"css-text-underline-offset": regexp.MustCompile(`(?i)(^|\s|;)text-underline-offset(\s+)?:`),
|
||||
"css-transform": regexp.MustCompile(`(?i)(^|\s|;)transform(\s+)?:`),
|
||||
"css-unit-calc": regexp.MustCompile(`(?i)(\s|:)calc\(`),
|
||||
"css-variables": regexp.MustCompile(`(?i)(^|\s|;)variables(\s+)?:`),
|
||||
"css-visibility": regexp.MustCompile(`(?i)(^|\s|;)visibility(\s+)?:`),
|
||||
"css-white-space": regexp.MustCompile(`(?i)(^|\s|;)white-space(\s+)?:`),
|
||||
"css-width": regexp.MustCompile(`(?i)(^|\s|;)width(\s+)?:`),
|
||||
"css-word-break": regexp.MustCompile(`(?i)(^|\s|;)word-break(\s+)?:`),
|
||||
"css-writing-mode": regexp.MustCompile(`(?i)(^|\s|;)writing-mode(\s+)?:`),
|
||||
"css-z-index": regexp.MustCompile(`(?i)(^|\s|;)z-index(\s+)?:`),
|
||||
}
|
||||
|
||||
// some CSS tests using regex for things that can't be merged inline
|
||||
var cssRegexpTests = map[string]*regexp.Regexp{
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`),
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`),
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`),
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`),
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`),
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`),
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`),
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`),
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`),
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`),
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`),
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`),
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`),
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`),
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`),
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`),
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`),
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`),
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`),
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`),
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`),
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`),
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`),
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`),
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`),
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`),
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`),
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`),
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`),
|
||||
}
|
||||
|
||||
// some CSS tests using regex for units
|
||||
var cssRegexpUnitTests = map[string]*regexp.Regexp{
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`),
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`),
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`),
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`),
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`),
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`),
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -42,17 +45,15 @@ func runCSSTests(html string) ([]Warning, int, error) {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
for key, test := range cssInlineTests {
|
||||
totalTests++
|
||||
found := len(doc.Find(test).Nodes)
|
||||
if found > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
inlineStyleResults := testInlineStyles(doc)
|
||||
totalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)
|
||||
for key, count := range inlineStyleResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err == nil {
|
||||
result.Score.Found = count
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
@@ -143,19 +144,20 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
attributes := link.Attr
|
||||
for _, a := range attributes {
|
||||
if a.Key == "href" {
|
||||
if !isURL(a.Val) {
|
||||
// skip invalid URL
|
||||
continue
|
||||
}
|
||||
|
||||
if config.BlockRemoteCSSAndFonts {
|
||||
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if !isValidURL(a.Val) {
|
||||
// skip invalid URL
|
||||
logger.Log().Warnf("[html-check] ignoring unsupported stylesheet URL: %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := downloadCSSToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
|
||||
logger.Log().Warnf("[html-check] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -184,25 +186,41 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
return newDoc, nil
|
||||
}
|
||||
|
||||
// DownloadToBytes returns a []byte slice from a URL
|
||||
func downloadToBytes(url string) ([]byte, error) {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Get(url)
|
||||
// DownloadCSSToBytes returns a []byte slice from a URL.
|
||||
// It requires the HTTP response code to be 200 and the content-type to be text/css.
|
||||
// It will download a maximum of 5MB.
|
||||
func downloadCSSToBytes(url string) ([]byte, error) {
|
||||
client := newSafeHTTPClient()
|
||||
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit HTML Checker/"+config.Version)
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
err := fmt.Errorf("error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
ct := strings.ToLower(resp.Header.Get("content-type"))
|
||||
if !strings.Contains(ct, "text/css") {
|
||||
err := fmt.Errorf("invalid CSS content-type from %s: \"%s\" (expected \"text/css\")", url, ct)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set a limit on the number of bytes to read - max 5MB
|
||||
limit := int64(5242880)
|
||||
limitedReader := &io.LimitedReader{R: resp.Body, N: limit}
|
||||
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,8 +228,83 @@ func downloadToBytes(url string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
// Test if the string is a supported URL.
|
||||
// The URL must have the "http" or "https" scheme, and must not contain any login info (http://user:pass@<host>).
|
||||
func isValidURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && u.User.String() == ""
|
||||
}
|
||||
|
||||
// Test the HTML for inline CSS styles and styling attributes
|
||||
func testInlineStyles(doc *goquery.Document) map[string]int {
|
||||
matches := make(map[string]int)
|
||||
|
||||
// find all elements containing a style attribute
|
||||
styles := doc.Find("[style]").Nodes
|
||||
for _, s := range styles {
|
||||
style, err := tools.GetHTMLAttributeVal(s, "style")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for id, test := range cssInlineRegexTests {
|
||||
if test.MatchString(style) {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = 0
|
||||
}
|
||||
matches[id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find all elements containing styleInlineAttributes
|
||||
for id, test := range styleInlineAttributes {
|
||||
a := doc.Find(test).Nodes
|
||||
if len(a) > 0 {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = 0
|
||||
}
|
||||
matches[id]++
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func newSafeHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: nil, // avoid env proxy surprises unless you explicitly want it
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
MaxIdleConns: 50,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// re-validate every redirect hop.
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
if !isValidURL(req.URL.String()) {
|
||||
return errors.New("invalid redirect URL")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
81
internal/htmlcheck/inline_test.go
Normal file
81
internal/htmlcheck/inline_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func TestInlineStyleDetection(t *testing.T) {
|
||||
/// tests should contain the HTML test, and expected test results in alphabetical order
|
||||
tests := map[string]string{}
|
||||
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="color: green; transform:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="color:green; transform :rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="transform:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="TRANSFORM:rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
|
||||
tests[`<h1 style="ignore-transform: something">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="text-transform: uppercase">Heading</h1>`] = "css-text-transform"
|
||||
tests[`<h1 style="text-transform: uppercase; text-transform: uppercase">Heading</h1>`] = "css-text-transform"
|
||||
tests[`<h1 style="test-transform: uppercase">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="padding-inline-start: 5rem">Heading</h1>`] = "css-padding-inline-start-end"
|
||||
tests[`<h1 style="margin-inline-end: 5rem">Heading</h1>`] = "css-margin-inline-start-end"
|
||||
tests[`<h1 style="margin-inline-middle: 5rem">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="color:green!important">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color: green !important">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color: green!important;">Heading</h1>`] = "css-important"
|
||||
tests[`<h1 style="color:green!important-stuff;">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 style="background-image:url('img.jpg')">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style="background-image:url('img.jpg'); color: green">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style=" color:green; background-image:url('img.jpg');">Heading</h1>`] = "css-background-image"
|
||||
tests[`<h1 style="display : flex ;">Heading</h1>`] = "css-display,css-display-flex"
|
||||
tests[`<h1 style="DISPLAY:FLEX;">Heading</h1>`] = "css-display,css-display-flex"
|
||||
tests[`<h1 style="display: flexing;">Heading</h1>`] = "css-display" // should not match css-display-flex rule
|
||||
tests[`<h1 style="line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)">Heading</h1>`] = "css-line-height,css-opacity,css-unit-calc,css-width"
|
||||
tests[`<h1 style="color: rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
|
||||
tests[`<h1 style="color:rgba(255,255,255, 0);">Heading</h1>`] = "css-rgba"
|
||||
tests[`<h1 style="border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);">Heading</h1>`] = "css-border,css-rgb,css-rgba"
|
||||
tests[`<h1 border="2">Heading</h1>`] = "css-border"
|
||||
tests[`<h1 border="2" background="green">Heading</h1>`] = "css-background,css-border"
|
||||
tests[`<h1 BORDER="2" BACKGROUND="GREEN">Heading</h1>`] = "css-background,css-border"
|
||||
tests[`<h1 border-something="2" background-something="green">Heading</h1>`] = "" // no match
|
||||
tests[`<h1 border="2" style="border: solid green 1px!important">Heading</h1>`] = "css-border,css-important"
|
||||
|
||||
for html, expected := range tests {
|
||||
reader := strings.NewReader(html)
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
results := testInlineStyles(doc)
|
||||
|
||||
matches := []string{}
|
||||
uniqMap := make(map[string]bool)
|
||||
for key := range results {
|
||||
if _, exists := uniqMap[key]; !exists {
|
||||
matches = append(matches, key)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure results are sorted to ensure consistent results
|
||||
sort.Strings(matches)
|
||||
|
||||
assertEqual(t, expected, strings.Join(matches, ","), fmt.Sprintf("inline style detection \"%s\"", html))
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
@@ -152,19 +152,20 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
switch support {
|
||||
case "y":
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
case "n":
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
default:
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
noteIDs := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
for _, id := range noteIDs {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,14 @@ var (
|
||||
</html>`
|
||||
|
||||
expectedHTMLLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "https://localhost", "https://127.0.0.1", "http://link with spaces", "http://example.com/?blaah=yes&test=true",
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"http://localhost",
|
||||
"https://localhost",
|
||||
"https://127.0.0.1",
|
||||
"http://link with spaces",
|
||||
"http://example.com/?blaah=yes&test=true",
|
||||
"http://remote-host/style.css", // css
|
||||
"https://example.com/image.jpg", // images
|
||||
}
|
||||
@@ -41,10 +48,18 @@ var (
|
||||
[http://localhost]
|
||||
www.google.com < ignored
|
||||
|||http://example.com/?some=query-string|||
|
||||
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
|
||||
// recognize potential spaces in between the URL
|
||||
<https://example.com/ link with spaces>
|
||||
`
|
||||
|
||||
expectedTextLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string",
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"http://localhost",
|
||||
"http://example.com/?some=query-string",
|
||||
"https://example.com/ link with spaces",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -30,9 +30,28 @@ func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
}
|
||||
|
||||
func extractTextLinks(msg *storage.Message) []string {
|
||||
testLinkRe := regexp.MustCompile(`(?im)([^<]\b)((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+))`)
|
||||
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
|
||||
// recognize potential spaces in between the URL
|
||||
// @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E
|
||||
bracketLinkRe := regexp.MustCompile(`(?im)<((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;][^>]+))>`)
|
||||
|
||||
links := []string{}
|
||||
|
||||
links = append(links, linkRe.FindAllString(msg.Text, -1)...)
|
||||
matches := testLinkRe.FindAllStringSubmatch(msg.Text, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) > 0 {
|
||||
links = append(links, match[2])
|
||||
}
|
||||
}
|
||||
|
||||
angleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, -1)
|
||||
for _, match := range angleMatches {
|
||||
if len(match) > 0 {
|
||||
link := strings.ReplaceAll(match[1], "\n", "")
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
@@ -34,6 +40,10 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
if err != nil {
|
||||
l.StatusCode = 0
|
||||
l.Status = httpErrorSummary(err)
|
||||
if strings.Contains(l.Status, "private/reserved address") {
|
||||
l.Status = "Blocked private/reserved address"
|
||||
l.StatusCode = 451
|
||||
}
|
||||
} else {
|
||||
l.StatusCode = code
|
||||
l.Status = http.StatusText(code)
|
||||
@@ -57,23 +67,37 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
|
||||
// Do a HEAD request to return HTTP status code
|
||||
func doHead(link string, followRedirects bool) (int, error) {
|
||||
if !tools.IsValidLinkURL(link) {
|
||||
return 0, fmt.Errorf("invalid URL: %s", link)
|
||||
}
|
||||
|
||||
timeout := time.Duration(10 * time.Second)
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
tr := &http.Transport{}
|
||||
tr := &http.Transport{
|
||||
DialContext: safeDialContext(dialer),
|
||||
}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if followRedirects {
|
||||
return nil
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
if !followRedirects {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if !tools.IsValidLinkURL(req.URL.String()) {
|
||||
return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,7 +116,6 @@ func doHead(link string, followRedirects bool) (int, error) {
|
||||
}
|
||||
|
||||
return 0, err
|
||||
|
||||
}
|
||||
|
||||
return res.StatusCode, nil
|
||||
@@ -107,8 +130,33 @@ func httpErrorSummary(err error) string {
|
||||
if !re.MatchString(e) {
|
||||
return e
|
||||
}
|
||||
|
||||
parts := re.FindAllStringSubmatch(e, -1)
|
||||
|
||||
return parts[0][len(parts[0])-1]
|
||||
}
|
||||
|
||||
// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.
|
||||
func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !config.AllowInternalHTTPRequests {
|
||||
for _, ip := range ips {
|
||||
if tools.IsInternalIP(ip.IP) {
|
||||
logger.Log().Warnf("[link-check] Blocked HEAD request to private/reserved address: %s (%s)", host, ip)
|
||||
return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +66,6 @@ func PrettyPrint(i interface{}) {
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
|
||||
// CleanIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
|
||||
func CleanIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
return "0.0.0.0:" + s[5:]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// CleanHTTPIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
func CleanHTTPIP(s string) string {
|
||||
|
||||
@@ -18,7 +18,7 @@ func authUser(username, password string) bool {
|
||||
|
||||
// Send a response with debug logging
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
_, _ = fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
|
||||
if strings.HasPrefix(m, "-ERR ") {
|
||||
@@ -29,7 +29,7 @@ func sendResponse(c net.Conn, m string) {
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
_, _ = fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
// Get the latest 100 messages
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3client"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,22 +29,21 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// connect with bad password
|
||||
t.Log("Testing invalid login")
|
||||
c, err := connectBadAuth()
|
||||
if err == nil {
|
||||
if _, err := connectBadAuth(); err == nil {
|
||||
t.Error("invalid login gained access")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Testing valid login")
|
||||
c, err = connectAuth()
|
||||
c, err := connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, size, err := c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// quit else we get old data
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,13 +62,13 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,29 +79,69 @@ func TestPOP3(t *testing.T) {
|
||||
for i := 1; i <= 20; i++ {
|
||||
_, err := c.Retr(i)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL with multiple arguments")
|
||||
|
||||
_, err = c.Cmd("UIDL", false, 1, 2, 3)
|
||||
if err == nil {
|
||||
t.Error("UIDL with multiple arguments should return an error")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL without a message id")
|
||||
|
||||
messageIDs, err := c.Uidl(0)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(messageIDs) != 50 {
|
||||
assertEqual(t, len(messageIDs), 50, "incorrect UIDL message count")
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL with a message ID")
|
||||
|
||||
messageIDs, err = c.Uidl(50)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, len(messageIDs), 1, "incorrect UIDL message count")
|
||||
|
||||
t.Log("Checking UIDL with an invalid message ID")
|
||||
|
||||
if _, err := c.Uidl(51); err == nil {
|
||||
t.Errorf("UIDL 51 should return an error")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Deleting 25 messages")
|
||||
|
||||
for i := 1; i <= 25; i++ {
|
||||
if err := c.Dele(i); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// messages get deleted after a QUIT
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// allow for background delete when using rqlite driver
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +149,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,13 +157,13 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// messages get deleted after a QUIT
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +171,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
for i := 1; i <= 25; i++ {
|
||||
if err := c.Dele(i); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -140,31 +179,31 @@ func TestPOP3(t *testing.T) {
|
||||
t.Log("Undeleting messages")
|
||||
|
||||
if err := c.Rset(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, 25, "incorrect message count")
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -187,7 +226,7 @@ func TestAuthentication(t *testing.T) {
|
||||
// non-authenticated connection
|
||||
c, err := connect()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,7 +243,7 @@ func TestAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,7 +252,7 @@ func TestAuthentication(t *testing.T) {
|
||||
// authenticated connection
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,13 +269,15 @@ func TestAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setup() {
|
||||
auth.SetPOP3Auth("username:password")
|
||||
if err := auth.SetPOP3Auth("username:password"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
@@ -343,7 +384,7 @@ func insertEmailData(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
id, err := storage.Store(&bufBytes)
|
||||
id, err := storage.Store(&bufBytes, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -80,7 +80,7 @@ func Run() {
|
||||
|
||||
type message struct {
|
||||
ID string
|
||||
Size float64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
@@ -211,28 +211,59 @@ func handleClient(conn net.Conn) {
|
||||
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
|
||||
switch cmd {
|
||||
case "STAT":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize))
|
||||
case "LIST":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
if len(args) > 0 {
|
||||
arg, _ := getSafeArg(args, 0)
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil || nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", nr, messages[nr-1].Size))
|
||||
} else {
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, m.Size))
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
case "UIDL":
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
|
||||
if len(args) > 1 {
|
||||
sendResponse(conn, "-ERR UIDL takes at most one argument")
|
||||
} else if len(args) == 1 {
|
||||
nr, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
if nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
m := messages[nr-1]
|
||||
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %s", nr, m.ID))
|
||||
} else {
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
|
||||
case "RETR":
|
||||
if len(args) != 1 {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
@@ -260,7 +291,7 @@ func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages
|
||||
// begins with the termination octet, the line is "byte-stuffed" by
|
||||
// pre-pending the termination octet to that line of the response.
|
||||
// @see: https://www.ietf.org/rfc/rfc1939.txt
|
||||
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
|
||||
sendData(conn, strings.ReplaceAll(string(raw), "\n.", "\n.."))
|
||||
sendResponse(conn, ".")
|
||||
case "TOP":
|
||||
arg, err := getSafeArg(args, 0)
|
||||
@@ -422,7 +422,7 @@ func (c *Conn) Noop() error {
|
||||
// Message deletions (DELE command) are only executed by the server on a graceful
|
||||
// quit and close.
|
||||
func (c *Conn) Quit() error {
|
||||
defer c.conn.Close()
|
||||
defer func() { _ = c.conn.Close() }()
|
||||
|
||||
if _, err := c.Cmd("QUIT", false); err != nil {
|
||||
return err
|
||||
|
||||
191
internal/prometheus/metrics.go
Normal file
191
internal/prometheus/metrics.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Package prometheus provides Prometheus metrics for Mailpit
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Registry is the Prometheus registry for Mailpit metrics
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Metrics
|
||||
totalMessages prometheus.Gauge
|
||||
unreadMessages prometheus.Gauge
|
||||
databaseSize prometheus.Gauge
|
||||
messagesDeleted prometheus.Counter
|
||||
smtpAccepted prometheus.Counter
|
||||
smtpRejected prometheus.Counter
|
||||
smtpIgnored prometheus.Counter
|
||||
smtpAcceptedSize prometheus.Counter
|
||||
uptime prometheus.Gauge
|
||||
memoryUsage prometheus.Gauge
|
||||
tagCounters *prometheus.GaugeVec
|
||||
)
|
||||
|
||||
// InitMetrics initializes all Prometheus metrics
|
||||
func initMetrics() {
|
||||
// Create metrics
|
||||
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages",
|
||||
Help: "Total number of messages in the database",
|
||||
})
|
||||
|
||||
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages_unread",
|
||||
Help: "Number of unread messages in the database",
|
||||
})
|
||||
|
||||
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_database_size_bytes",
|
||||
Help: "Size of the database in bytes",
|
||||
})
|
||||
|
||||
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_messages_deleted_total",
|
||||
Help: "Total number of messages deleted",
|
||||
})
|
||||
|
||||
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_total",
|
||||
Help: "Total number of SMTP messages accepted",
|
||||
})
|
||||
|
||||
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_rejected_total",
|
||||
Help: "Total number of SMTP messages rejected",
|
||||
})
|
||||
|
||||
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_ignored_total",
|
||||
Help: "Total number of SMTP messages ignored (duplicates)",
|
||||
})
|
||||
|
||||
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_size_bytes_total",
|
||||
Help: "Total size of accepted SMTP messages in bytes",
|
||||
})
|
||||
|
||||
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_uptime_seconds",
|
||||
Help: "Uptime of Mailpit in seconds",
|
||||
})
|
||||
|
||||
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_memory_usage_bytes",
|
||||
Help: "Memory usage in bytes",
|
||||
})
|
||||
|
||||
tagCounters = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "mailpit_tag_messages",
|
||||
Help: "Number of messages per tag",
|
||||
},
|
||||
[]string{"tag"},
|
||||
)
|
||||
|
||||
// Register metrics
|
||||
Registry.MustRegister(totalMessages)
|
||||
Registry.MustRegister(unreadMessages)
|
||||
Registry.MustRegister(databaseSize)
|
||||
Registry.MustRegister(messagesDeleted)
|
||||
Registry.MustRegister(smtpAccepted)
|
||||
Registry.MustRegister(smtpRejected)
|
||||
Registry.MustRegister(smtpIgnored)
|
||||
Registry.MustRegister(smtpAcceptedSize)
|
||||
Registry.MustRegister(uptime)
|
||||
Registry.MustRegister(memoryUsage)
|
||||
Registry.MustRegister(tagCounters)
|
||||
}
|
||||
|
||||
// UpdateMetrics updates all metrics with current values
|
||||
func updateMetrics() {
|
||||
info := stats.Load(false)
|
||||
|
||||
totalMessages.Set(float64(info.Messages))
|
||||
unreadMessages.Set(float64(info.Unread))
|
||||
databaseSize.Set(float64(info.DatabaseSize))
|
||||
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
uptime.Set(float64(info.RuntimeStats.Uptime))
|
||||
memoryUsage.Set(float64(info.RuntimeStats.Memory))
|
||||
|
||||
// Reset tag counters
|
||||
tagCounters.Reset()
|
||||
|
||||
// Update tag counters
|
||||
for tag, count := range info.Tags {
|
||||
tagCounters.WithLabelValues(tag).Set(float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus handler & disables double compression in middleware
|
||||
func GetHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
})
|
||||
}
|
||||
|
||||
// StartUpdater starts the periodic metrics update routine
|
||||
func StartUpdater() {
|
||||
initMetrics()
|
||||
updateMetrics()
|
||||
|
||||
// Start periodic updates
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
updateMetrics()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartSeparateServer starts a separate HTTP server for Prometheus metrics
|
||||
func StartSeparateServer() {
|
||||
StartUpdater()
|
||||
|
||||
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
|
||||
|
||||
// Create a dedicated mux for the metrics server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||
|
||||
// Create a dedicated server instance
|
||||
server := &http.Server{
|
||||
Addr: config.PrometheusListen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// GetMode returns the Prometheus run mode
|
||||
func GetMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
|
||||
|
||||
switch mode {
|
||||
case "false", "":
|
||||
return "disabled"
|
||||
case "true":
|
||||
return "integrated"
|
||||
default:
|
||||
return "separate"
|
||||
}
|
||||
}
|
||||
123
internal/smtpd/chaos/chaos.go
Normal file
123
internal/smtpd/chaos/chaos.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
|
||||
// See https://en.wikipedia.org/wiki/Chaos_engineering
|
||||
// See https://mailpit.axllent.org/docs/integration/chaos/
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
// Enabled is a flag to enable or disable support for chaos
|
||||
Enabled = false
|
||||
|
||||
// Config is the global Chaos configuration
|
||||
Config = Triggers{
|
||||
Sender: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Recipient: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Authentication: Trigger{ErrorCode: 535, Probability: 0},
|
||||
}
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
//
|
||||
// swagger:model ChaosTriggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
Sender Trigger
|
||||
// Recipient trigger to fail on To, Cc, Bcc
|
||||
Recipient Trigger
|
||||
// Authentication trigger to fail while authenticating (auth must be configured)
|
||||
Authentication Trigger
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
//
|
||||
// swagger:model ChaosTrigger
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
// example: 451
|
||||
ErrorCode int
|
||||
|
||||
// Probability (chance) of triggering the error. The value must range from 0 to 100.
|
||||
// required: true
|
||||
// example: 5
|
||||
Probability int
|
||||
}
|
||||
|
||||
// SetFromStruct will set a whole map of chaos configurations (ie: API)
|
||||
func SetFromStruct(c Triggers) error {
|
||||
if c.Sender.ErrorCode == 0 {
|
||||
c.Sender.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Recipient.ErrorCode == 0 {
|
||||
c.Recipient.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Authentication.ErrorCode == 0 {
|
||||
c.Authentication.ErrorCode = 535 // default
|
||||
}
|
||||
|
||||
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set will set the chaos configuration for the given key (CLI & setMap())
|
||||
func Set(key string, errorCode int, probability int) error {
|
||||
Enabled = true
|
||||
if errorCode < 400 || errorCode > 599 {
|
||||
return fmt.Errorf("error code must be between 400 and 599")
|
||||
}
|
||||
|
||||
if probability > 100 || probability < 0 {
|
||||
return fmt.Errorf("probability must be between 0 and 100")
|
||||
}
|
||||
|
||||
key = strings.ToLower(key)
|
||||
|
||||
switch key {
|
||||
case "sender":
|
||||
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
|
||||
case "recipient", "recipients":
|
||||
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
|
||||
case "auth", "authentication":
|
||||
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
|
||||
default:
|
||||
return fmt.Errorf("unknown key %s", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trigger will return whether the Chaos rule is triggered based on the configuration
|
||||
// and a randomly-generated percentage value.
|
||||
func (c Trigger) Trigger() (bool, int) {
|
||||
if !Enabled || c.Probability == 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
|
||||
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
|
||||
// so value must be less than (not <=) to the probability to trigger
|
||||
return int(nBig.Int64()) < c.Probability, c.ErrorCode
|
||||
}
|
||||
155
internal/smtpd/forward.go
Normal file
155
internal/smtpd/forward.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Wrapper to forward messages if configured
|
||||
func autoForwardMessage(from string, data *[]byte) error {
|
||||
if config.SMTPForwardConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := forward(from, *data); err != nil {
|
||||
return errors.WithMessage(err, "[forward] error: %s")
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
"[forward] message from %s to %s via %s:%d",
|
||||
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {
|
||||
if config.TLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TLS dial error: %v", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
// Set the hostname for HELO/EHLO
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if err := client.Hello(hostname); err != nil {
|
||||
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func forward(from string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
|
||||
|
||||
c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
auth := forwardAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPForwardConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
to := strings.Split(config.SMTPForwardConfig.To, ",")
|
||||
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
if config.SMTPForwardConfig.ForwardSMTPErrors {
|
||||
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP forwarding authentication based on config
|
||||
func forwardAuthFromConfig() smtp.Auth {
|
||||
var a smtp.Auth
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "plain" {
|
||||
a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "login" {
|
||||
a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "cram-md5" {
|
||||
a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,13 +29,13 @@ var (
|
||||
)
|
||||
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data)
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data, smtpUser)
|
||||
}
|
||||
|
||||
// SaveToDatabase will attempt to save a message to the database
|
||||
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
if !config.SMTPStrictRFCHeaders {
|
||||
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) {
|
||||
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
|
||||
@@ -50,32 +51,20 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
// check / set the Return-Path based on SMTP from
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
if returnPath != from {
|
||||
if returnPath != "" {
|
||||
// replace Return-Path
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
|
||||
})
|
||||
} else {
|
||||
// add Return-Path
|
||||
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
|
||||
data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
|
||||
messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>")
|
||||
|
||||
// add a message ID if not set
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
messageID = shortuuid.New() + "@mailpit"
|
||||
// add unique ID
|
||||
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
|
||||
data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...)
|
||||
} else if config.IgnoreDuplicateIDs {
|
||||
if storage.MessageIDExists(messageID) {
|
||||
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
|
||||
@@ -85,7 +74,36 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
}
|
||||
|
||||
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
|
||||
autoRelayMessage(from, to, &data)
|
||||
if relayErr := autoRelayMessage(from, to, &data); relayErr != nil {
|
||||
logger.Log().Error(relayErr.Error())
|
||||
|
||||
if config.SMTPRelayConfig.ForwardSMTPErrors {
|
||||
for {
|
||||
unwrappedErr := errors.Unwrap(relayErr)
|
||||
if unwrappedErr == nil {
|
||||
break
|
||||
}
|
||||
relayErr = unwrappedErr
|
||||
}
|
||||
return "", relayErr
|
||||
}
|
||||
}
|
||||
|
||||
// if enabled, this will forward a copy to preconfigured addresses
|
||||
if forwardErr := autoForwardMessage(from, &data); forwardErr != nil {
|
||||
logger.Log().Error(forwardErr.Error())
|
||||
|
||||
if config.SMTPForwardConfig.ForwardSMTPErrors {
|
||||
for {
|
||||
unwrappedErr := errors.Unwrap(forwardErr)
|
||||
if unwrappedErr == nil {
|
||||
break
|
||||
}
|
||||
forwardErr = unwrappedErr
|
||||
}
|
||||
return "", forwardErr
|
||||
}
|
||||
}
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
@@ -105,29 +123,21 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
|
||||
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
|
||||
if len(missingAddresses) > 0 {
|
||||
bccVal := strings.Join(missingAddresses, ", ")
|
||||
if hasBccHeader {
|
||||
// email already has Bcc header, add to existing addresses
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
b := msg.Header.Get("Bcc")
|
||||
bccVal = ", " + b
|
||||
}
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
|
||||
})
|
||||
|
||||
} else {
|
||||
// prepend new Bcc header
|
||||
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
|
||||
data = append(bcc, data...)
|
||||
data, err = tools.SetMessageHeader(data, "Bcc", bccVal)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
|
||||
}
|
||||
|
||||
id, err := storage.Store(&data)
|
||||
id, err := storage.Store(&data, smtpUser)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
return "", err
|
||||
@@ -211,15 +221,16 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
|
||||
Debug = true // to enable Mailpit logging
|
||||
srv := &Server{
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
AppName: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
AppName: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
IgnoreRejectedRecipients: config.SMTPIgnoreRejectedRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
LogRead: func(remoteIP, verb, line string) {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
},
|
||||
@@ -241,15 +252,27 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthMechs = map[string]bool{
|
||||
"CRAM-MD5": false,
|
||||
"PLAIN": true,
|
||||
"LOGIN": true,
|
||||
}
|
||||
}
|
||||
|
||||
if auth.SMTPCredentials != nil {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.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.AuthMechs = map[string]bool{
|
||||
"CRAM-MD5": false,
|
||||
"PLAIN": true,
|
||||
"LOGIN": true,
|
||||
}
|
||||
srv.AuthHandler = authHandlerAny
|
||||
}
|
||||
|
||||
@@ -279,10 +302,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
smtpType := "no encryption"
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
if config.SMTPRequireSTARTTLS {
|
||||
smtpType = "STARTTLS required"
|
||||
} else if config.SMTPRequireTLS {
|
||||
if config.SMTPRequireTLS {
|
||||
smtpType = "SSL/TLS required"
|
||||
} else if config.SMTPRequireSTARTTLS {
|
||||
smtpType = "STARTTLS required"
|
||||
} else {
|
||||
smtpType = "STARTTLS optional"
|
||||
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {
|
||||
221
internal/smtpd/relay.go
Normal file
221
internal/smtpd/relay.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Wrapper to auto relay messages if configured
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) error {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
|
||||
filteredTo := []string{}
|
||||
for _, address := range to {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
|
||||
logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address)
|
||||
continue
|
||||
}
|
||||
|
||||
filteredTo = append(filteredTo, address)
|
||||
}
|
||||
to = filteredTo
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.SMTPRelayAll {
|
||||
if err := Relay(from, to, *data); err != nil {
|
||||
return errors.WithMessage(err, "[relay] error")
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
"[relay] sent message to %s from %s via %s:%d",
|
||||
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,
|
||||
)
|
||||
} else if config.SMTPRelayMatchingRegexp != nil {
|
||||
filtered := []string{}
|
||||
for _, t := range to {
|
||||
if config.SMTPRelayMatchingRegexp.MatchString(t) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := Relay(from, filtered, *data); err != nil {
|
||||
return errors.WithMessage(err, "[relay] error")
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
"[relay] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {
|
||||
if config.TLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TLS dial error: %v", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
// Set the hostname for HELO/EHLO
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if err := client.Hello(hostname); err != nil {
|
||||
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The caller is responsible for closing the client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Relay(from string, to []string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := createRelaySMTPClient(config.SMTPRelayConfig, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
auth := relayAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPRelayConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return errors.WithMessage(err, "error sending MAIL command")
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
if config.SMTPRelayConfig.ForwardSMTPErrors {
|
||||
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "error response to DATA command")
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return errors.WithMessage(err, "error sending message")
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return errors.WithMessage(err, "error closing connection")
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP relay authentication based on config
|
||||
func relayAuthFromConfig() smtp.Auth {
|
||||
var a smtp.Auth
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "plain" {
|
||||
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "login" {
|
||||
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "cram-md5" {
|
||||
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// Custom implementation of LOGIN SMTP authentication
|
||||
// @see https://gist.github.com/andelf/5118732
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
// LoginAuth authentication
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("unknown fromServer")
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package smtpd implements a basic SMTP server.
|
||||
//
|
||||
// This is a modified version of https://github.com/mhale/smtpd to
|
||||
// add optional support for unix sockets.
|
||||
// add support for unix sockets and Mailpit Chaos.
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -22,14 +23,18 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
var (
|
||||
// Debug `true` enables verbose logging.
|
||||
Debug = false
|
||||
rcptToRE = regexp.MustCompile(`[Tt][Oo]:\s?<(.+)>`)
|
||||
mailFromRE = regexp.MustCompile(`[Ff][Rr][Oo][Mm]:\s?<(.*)>(\s(.*))?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
mailSizeRE = regexp.MustCompile(`[Ss][Ii][Zz][Ee]=(\d+)`)
|
||||
rcptToRE = regexp.MustCompile(`(?i)TO: ?<([^<>\v]+)>( |$)(.*)?`)
|
||||
mailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
|
||||
// extract mail size from 'MAIL FROM' parameter
|
||||
mailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`)
|
||||
)
|
||||
|
||||
// Handler function called upon successful receipt of an email.
|
||||
@@ -38,7 +43,7 @@ type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) er
|
||||
|
||||
// MsgIDHandler function called upon successful receipt of an email. Returns a message ID.
|
||||
// Results in a "250 2.0.0 Ok: queued as <message-id>" response.
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte) (string, error)
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error)
|
||||
|
||||
// HandlerRcpt function called on RCPT. Return accept status.
|
||||
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
|
||||
@@ -88,26 +93,27 @@ type LogFunc func(remoteIP, verb, line string)
|
||||
|
||||
// Server is an SMTP server.
|
||||
type Server struct {
|
||||
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
|
||||
AppName string
|
||||
AuthHandler AuthHandler
|
||||
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
|
||||
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
|
||||
DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname
|
||||
Handler Handler
|
||||
HandlerRcpt HandlerRcpt
|
||||
Hostname string
|
||||
LogRead LogFunc
|
||||
LogWrite LogFunc
|
||||
MaxSize int // Maximum message size allowed, in bytes
|
||||
MaxRecipients int // Maximum number of recipients, defaults to 100.
|
||||
MsgIDHandler MsgIDHandler
|
||||
Timeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
|
||||
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
|
||||
Protocol string // Default tcp, supports unix
|
||||
SocketPerm fs.FileMode // if using Unix socket, socket permissions
|
||||
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
|
||||
AppName string
|
||||
AuthHandler AuthHandler
|
||||
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
|
||||
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
|
||||
DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname
|
||||
Handler Handler
|
||||
HandlerRcpt HandlerRcpt
|
||||
Hostname string
|
||||
LogRead LogFunc
|
||||
LogWrite LogFunc
|
||||
MaxSize int // Maximum message size allowed, in bytes
|
||||
MaxRecipients int // Maximum number of recipients, defaults to 100.
|
||||
MsgIDHandler MsgIDHandler
|
||||
IgnoreRejectedRecipients bool // Accept emails to rejected recipients with 2xx response but silently drop them
|
||||
Timeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
|
||||
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
|
||||
Protocol string // Default tcp, supports unix
|
||||
SocketPerm fs.FileMode // if using Unix socket, socket permissions
|
||||
|
||||
inShutdown int32 // server was closed or shutdown
|
||||
openSessions int32 // count of open sessions
|
||||
@@ -213,7 +219,7 @@ func (srv *Server) Serve(ln net.Listener) error {
|
||||
return ErrServerClosed
|
||||
}
|
||||
|
||||
defer ln.Close()
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
for {
|
||||
// if we are shutting down, don't accept new connections
|
||||
@@ -225,7 +231,7 @@ func (srv *Server) Serve(ln net.Listener) error {
|
||||
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
@@ -251,6 +257,7 @@ type session struct {
|
||||
xClientTrust bool // Trust XCLIENT from current IP address
|
||||
tls bool
|
||||
authenticated bool
|
||||
username *string // username, nil if not authenticated
|
||||
}
|
||||
|
||||
// Create new session from connection.
|
||||
@@ -351,13 +358,22 @@ func (srv *Server) Shutdown(ctx context.Context) error {
|
||||
// Function called to handle connection requests.
|
||||
func (s *session) serve() {
|
||||
defer atomic.AddInt32(&s.srv.openSessions, -1)
|
||||
defer s.conn.Close()
|
||||
// pass the connection into the defer function to ensure it is closed,
|
||||
// otherwise results in a 5s timeout for each connection
|
||||
defer func(c net.Conn) { _ = c.Close() }(s.conn)
|
||||
|
||||
var gotEHLO bool
|
||||
var from string
|
||||
var gotFrom bool
|
||||
var gotFROM bool
|
||||
var to []string
|
||||
var hasRejectedRecipients bool
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
// Send banner.
|
||||
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName)
|
||||
|
||||
@@ -382,18 +398,22 @@ loop:
|
||||
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
|
||||
gotEHLO = true
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "EHLO":
|
||||
s.remoteName = args
|
||||
s.writef(s.makeEHLOResponse())
|
||||
s.writef("%s", s.makeEHLOResponse())
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
|
||||
gotEHLO = true
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "MAIL":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -404,37 +424,60 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotEHLO {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)")
|
||||
break
|
||||
}
|
||||
if to != nil {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)")
|
||||
break
|
||||
}
|
||||
|
||||
match := mailFromRE.FindStringSubmatch(args)
|
||||
match, err := extractAndValidateAddress(mailFromRE, args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
if err != nil {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
}
|
||||
} else {
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Sender.Trigger(); fail {
|
||||
s.writef("%d Chaos sender error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// Validate the SIZE parameter if one was sent.
|
||||
if len(match[2]) > 0 { // A parameter is present
|
||||
sizeMatch := mailSizeRE.FindStringSubmatch(match[3])
|
||||
sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])
|
||||
if sizeMatch == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
// ignore other parameter
|
||||
from = match[1]
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
} else {
|
||||
// Enforce the maximum message size if one is set.
|
||||
size, err := strconv.Atoi(sizeMatch[1])
|
||||
size, err := strconv.Atoi(sizeMatch[2])
|
||||
if err != nil { // Bad SIZE parameter
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set
|
||||
err = maxSizeExceeded(s.srv.MaxSize)
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else { // SIZE ok
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
} else { // No parameters after FROM
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "RCPT":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -445,20 +488,26 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom {
|
||||
if !gotFROM {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
|
||||
break
|
||||
}
|
||||
|
||||
match := rcptToRE.FindStringSubmatch(args)
|
||||
match, err := extractAndValidateAddress(rcptToRE, args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
} else {
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
if err != nil {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
}
|
||||
if len(to) == s.srv.MaxRecipients {
|
||||
} else {
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Recipient.Trigger(); fail {
|
||||
s.writef("%d Chaos recipient error", code)
|
||||
break
|
||||
}
|
||||
|
||||
if len(to) >= s.srv.MaxRecipients {
|
||||
s.writef("452 4.5.3 Too many recipients")
|
||||
} else {
|
||||
accept := true
|
||||
@@ -468,6 +517,9 @@ loop:
|
||||
if accept {
|
||||
to = append(to, match[1])
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else if s.srv.IgnoreRejectedRecipients {
|
||||
hasRejectedRecipients = true
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else {
|
||||
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
|
||||
}
|
||||
@@ -482,7 +534,8 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom || len(to) == 0 {
|
||||
hasRecipients := len(to) > 0 || hasRejectedRecipients
|
||||
if !gotFROM || !hasRecipients {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
|
||||
break
|
||||
}
|
||||
@@ -495,14 +548,14 @@ loop:
|
||||
// On other errors, allow the client to try again.
|
||||
data, err := s.readData()
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
switch err := err.(type) {
|
||||
case net.Error:
|
||||
if err.(net.Error).Timeout() {
|
||||
if err.Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
|
||||
}
|
||||
break loop
|
||||
case maxSizeExceededError:
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
continue
|
||||
default:
|
||||
s.writef("451 4.3.0 Requested action aborted: local error in processing")
|
||||
@@ -512,28 +565,30 @@ loop:
|
||||
|
||||
// Create Received header & write message body into buffer.
|
||||
buffer.Reset()
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
if len(to) > 0 {
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
}
|
||||
buffer.Write(data)
|
||||
|
||||
// Pass mail on to handler.
|
||||
if s.srv.Handler != nil {
|
||||
// Pass mail on to handler only if there are valid recipients.
|
||||
if len(to) > 0 && s.srv.Handler != nil {
|
||||
err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
break
|
||||
}
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
} else if s.srv.MsgIDHandler != nil {
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
} else if len(to) > 0 && s.srv.MsgIDHandler != nil {
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username)
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
@@ -541,18 +596,26 @@ loop:
|
||||
}
|
||||
|
||||
if msgID != "" {
|
||||
s.writef("250 2.0.0 Ok: queued as " + msgID)
|
||||
s.writef("250 2.0.0 Ok: queued as %s", msgID)
|
||||
} else {
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
} else {
|
||||
if hasRejectedRecipients && Debug {
|
||||
if s.srv.LogWrite != nil {
|
||||
s.srv.LogWrite(s.remoteIP, "DEBUG", "Message from sender silently dropped (rejected recipients)")
|
||||
} else {
|
||||
log.Printf("%s DEBUG Message from sender silently dropped (rejected recipients)", s.remoteIP)
|
||||
}
|
||||
}
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
|
||||
// Reset for next mail.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "QUIT":
|
||||
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName)
|
||||
@@ -564,8 +627,9 @@ loop:
|
||||
}
|
||||
s.writef("250 2.0.0 Ok")
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "NOOP":
|
||||
s.writef("250 2.0.0 Ok")
|
||||
@@ -640,8 +704,9 @@ loop:
|
||||
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
|
||||
s.remoteName = ""
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "AUTH":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -661,7 +726,7 @@ loop:
|
||||
}
|
||||
|
||||
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
|
||||
if gotFrom || len(to) > 0 {
|
||||
if gotFROM || len(to) > 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
|
||||
break
|
||||
}
|
||||
@@ -680,6 +745,12 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Authentication.Trigger(); fail {
|
||||
s.writef("%d Chaos authentication error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
|
||||
// when attempting to use an unsupported authentication type.
|
||||
// Many servers return 5.7.4 ("Security features not supported") instead.
|
||||
@@ -698,7 +769,7 @@ loop:
|
||||
break loop
|
||||
}
|
||||
|
||||
s.writef(err.Error())
|
||||
s.writef("%s", err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
@@ -721,7 +792,7 @@ func (s *session) writef(format string, args ...interface{}) {
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(s.bw, line+"\r\n")
|
||||
_, _ = fmt.Fprintf(s.bw, "%s\r\n", line)
|
||||
_ = s.bw.Flush()
|
||||
|
||||
if Debug {
|
||||
@@ -856,7 +927,12 @@ func (s *session) makeEHLOResponse() (response string) {
|
||||
}
|
||||
}
|
||||
|
||||
response += "250 ENHANCEDSTATUSCODES"
|
||||
response += "250-ENHANCEDSTATUSCODES\r\n"
|
||||
// RFC 6531 specifies that the presence of SMTPUTF8 should include 8BITMIME
|
||||
// "Servers offering this extension MUST provide support for, and announce, the 8BITMIME extension"
|
||||
// https://www.rfc-editor.org/rfc/rfc6531#section-3.1:
|
||||
response += "250-8BITMIME\r\n"
|
||||
response += "250 SMTPUTF8" // last entry must use a space instead of a dash
|
||||
return
|
||||
}
|
||||
|
||||
@@ -864,7 +940,7 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
if arg == "" {
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -876,7 +952,7 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -889,6 +965,12 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
|
||||
if authenticated {
|
||||
uname := string(username)
|
||||
s.username = &uname
|
||||
} else {
|
||||
s.username = nil
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
@@ -917,6 +999,12 @@ func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
|
||||
if authenticated {
|
||||
uname := string(parts[1])
|
||||
s.username = &uname
|
||||
} else {
|
||||
s.username = nil
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
@@ -924,7 +1012,7 @@ func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">"
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
|
||||
data, err := s.readLine()
|
||||
if err != nil {
|
||||
@@ -950,3 +1038,35 @@ func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
// Extract and validate email address from a regex match.
|
||||
// This ensures that only RFC 5322 compliant email addresses are accepted (if set).
|
||||
func extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) {
|
||||
match := re.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.Contains(match[1], " ") {
|
||||
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
|
||||
}
|
||||
|
||||
// first argument will be the email address, validate it if not empty
|
||||
if match[1] != "" {
|
||||
a, err := mail.ParseAddress(match[1])
|
||||
if err != nil {
|
||||
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1
|
||||
// RFC states that the local part of an email address SHOULD not exceed 64 characters
|
||||
// and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620
|
||||
// it appears that investigated mail servers do not actually implement this limit, but rather enforce
|
||||
// a much larger limit (ie: 1024 characters).
|
||||
if len(a.Address) > 1024 {
|
||||
return nil, errors.New("500 The address is too long")
|
||||
}
|
||||
}
|
||||
|
||||
return match, nil
|
||||
}
|
||||
1931
internal/smtpd/smtpd_test.go
Normal file
1931
internal/smtpd/smtpd_test.go
Normal file
File diff suppressed because it is too large
Load Diff
196
internal/snakeoil/snakeoil.go
Normal file
196
internal/snakeoil/snakeoil.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package snakeoil provides functionality to generate a temporary self-signed certificates
|
||||
// for testing purposes. It generates a public and private key pair, stores them in the
|
||||
// OS's temporary directory, returning the paths to these files.
|
||||
package snakeoil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var keys = make(map[string]KeyPair)
|
||||
|
||||
// KeyPair holds the public and private key paths for a self-signed certificate.
|
||||
type KeyPair struct {
|
||||
Public string
|
||||
Private string
|
||||
}
|
||||
|
||||
// Certificates returns all configured self-signed certificates in use,
|
||||
// used for file deletion on exit.
|
||||
func Certificates() map[string]KeyPair {
|
||||
return keys
|
||||
}
|
||||
|
||||
// Public returns the path to a generated PEM-encoded RSA public key.
|
||||
func Public(str string) string {
|
||||
domains, key, err := parse(str)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if pair, ok := keys[key]; ok {
|
||||
return pair.Public
|
||||
}
|
||||
|
||||
private, public, err := generate(domains)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
keys[key] = KeyPair{
|
||||
Public: public,
|
||||
Private: private,
|
||||
}
|
||||
|
||||
return public
|
||||
}
|
||||
|
||||
// Private returns the path to a generated PEM-encoded RSA private key.
|
||||
func Private(str string) string {
|
||||
domains, key, err := parse(str)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if pair, ok := keys[key]; ok {
|
||||
return pair.Private
|
||||
}
|
||||
|
||||
private, public, err := generate(domains)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
keys[key] = KeyPair{
|
||||
Public: public,
|
||||
Private: private,
|
||||
}
|
||||
|
||||
return private
|
||||
}
|
||||
|
||||
// Parse takes the original string input, removes the "sans:" prefix,
|
||||
// splits the result into individual domains, and returns a slice of unique domains,
|
||||
// along with a unique key that is a comma-separated list of these domains.
|
||||
func parse(str string) ([]string, string, error) {
|
||||
// remove "sans:" prefix
|
||||
str = str[5:]
|
||||
var domains []string
|
||||
// split the string by commas and trim whitespace
|
||||
for domain := range strings.SplitSeq(str, ",") {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain != "" && !tools.InArray(domain, domains) {
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
}
|
||||
|
||||
if len(domains) == 0 {
|
||||
return domains, "", errors.New("no valid domains provided")
|
||||
}
|
||||
|
||||
// generate sha256 hash of the domains to create a unique key
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(strings.Join(domains, ",")))
|
||||
key := base64.URLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return domains, key, nil
|
||||
}
|
||||
|
||||
// Generate a new self-signed certificate and return a public & private key paths.
|
||||
func generate(domains []string) (string, string, error) {
|
||||
logger.Log().Infof("[tls] generating temp self-signed certificate for: %s", strings.Join(domains, ","))
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||
// PEM encoding of private key
|
||||
keyPEM := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: keyBytes,
|
||||
},
|
||||
)
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour)
|
||||
|
||||
// create certificate template
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(0),
|
||||
Subject: pkix.Name{
|
||||
CommonName: domains[0],
|
||||
Organization: []string{"Mailpit self-signed certificate"},
|
||||
},
|
||||
DNSNames: domains,
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
|
||||
// create certificate using template
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
}
|
||||
|
||||
// PEM encoding of certificate
|
||||
certPem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: derBytes,
|
||||
},
|
||||
)
|
||||
|
||||
// Store the paths to the generated keys
|
||||
priv, err := os.CreateTemp("", ".mailpit-*-private.pem")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if _, err := priv.Write(keyPEM); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := priv.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pub, err := os.CreateTemp("", ".mailpit-*-public.pem")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if _, err := pub.Write(certPem); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := pub.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return priv.Name(), pub.Name(), nil
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func Check(email []byte, timeout int) (Response, error) {
|
||||
return r, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&r)
|
||||
|
||||
|
||||
@@ -57,13 +57,6 @@ func SetService(s string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout defines the timeout
|
||||
func SetTimeout(t int) {
|
||||
if t > 0 {
|
||||
timeout = t
|
||||
}
|
||||
}
|
||||
|
||||
// Ping returns whether a service is active or not
|
||||
func Ping() error {
|
||||
if service == "postmark" {
|
||||
|
||||
@@ -70,21 +70,22 @@ type Result struct {
|
||||
|
||||
// dial connects to spamd through TCP or a Unix socket.
|
||||
func (c *Client) dial() (connection, error) {
|
||||
if c.net == "tcp" {
|
||||
switch c.net {
|
||||
case "tcp":
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialTCP("tcp", nil, tcpAddr)
|
||||
} else if c.net == "unix" {
|
||||
case "unix":
|
||||
unixAddr, err := net.ResolveUnixAddr("unix", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialUnix("unix", nil, unixAddr)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported network type: %s", c.net)
|
||||
}
|
||||
|
||||
panic("Client.net must be either \"tcp\" or \"unix\"")
|
||||
}
|
||||
|
||||
// Report checks if message is spam or not, and returns score plus report
|
||||
@@ -103,7 +104,7 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return nil, err
|
||||
@@ -221,7 +222,7 @@ func (c *Client) Ping() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,23 +7,36 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Stores cached version along with its expiry time and error count.
|
||||
// Used to minimize repeated version lookups and track consecutive errors.
|
||||
type versionCache struct {
|
||||
// github version string
|
||||
value string
|
||||
// time to expire the cache
|
||||
expiry time.Time
|
||||
// count of consecutive errors
|
||||
errCount int
|
||||
}
|
||||
|
||||
var (
|
||||
// to prevent hammering Github for latest version
|
||||
latestVersionCache string
|
||||
// Version cache storing the latest GitHub version
|
||||
vCache versionCache
|
||||
|
||||
// StartedAt is set to the current ime when Mailpit starts
|
||||
startedAt time.Time
|
||||
|
||||
// sync mutex to prevent race condition with simultaneous requests
|
||||
mu sync.RWMutex
|
||||
|
||||
smtpAccepted float64
|
||||
smtpAcceptedSize float64
|
||||
smtpRejected float64
|
||||
smtpIgnored float64
|
||||
smtpAccepted uint64
|
||||
smtpAcceptedSize uint64
|
||||
smtpRejected uint64
|
||||
smtpIgnored uint64
|
||||
)
|
||||
|
||||
// AppInformation struct
|
||||
@@ -36,34 +49,40 @@ type AppInformation struct {
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize float64
|
||||
DatabaseSize uint64
|
||||
// Total number of messages in the database
|
||||
Messages float64
|
||||
Messages uint64
|
||||
// Total number of messages in the database
|
||||
Unread float64
|
||||
Unread uint64
|
||||
// Tags and message totals per tag
|
||||
Tags map[string]int64
|
||||
// Runtime statistics
|
||||
RuntimeStats struct {
|
||||
// Mailpit server uptime in seconds
|
||||
Uptime float64
|
||||
Uptime uint64
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
// Database runtime messages deleted
|
||||
MessagesDeleted float64
|
||||
MessagesDeleted uint64
|
||||
// Accepted runtime SMTP messages
|
||||
SMTPAccepted float64
|
||||
SMTPAccepted uint64
|
||||
// Total runtime accepted messages size in bytes
|
||||
SMTPAcceptedSize float64
|
||||
SMTPAcceptedSize uint64
|
||||
// Rejected runtime SMTP messages
|
||||
SMTPRejected float64
|
||||
SMTPRejected uint64
|
||||
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
|
||||
SMTPIgnored float64
|
||||
SMTPIgnored uint64
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates exponential backoff duration based on the error count.
|
||||
func getBackoff(errCount int) time.Duration {
|
||||
backoff := min(time.Duration(1<<errCount)*time.Minute, 30*time.Minute)
|
||||
return backoff
|
||||
}
|
||||
|
||||
// Load the current statistics
|
||||
func Load() AppInformation {
|
||||
func Load(detectLatestVersion bool) AppInformation {
|
||||
info := AppInformation{}
|
||||
info.Version = config.Version
|
||||
|
||||
@@ -71,26 +90,42 @@ func Load() AppInformation {
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
|
||||
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
|
||||
info.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds())
|
||||
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
|
||||
info.RuntimeStats.SMTPAccepted = smtpAccepted
|
||||
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
|
||||
info.RuntimeStats.SMTPRejected = smtpRejected
|
||||
info.RuntimeStats.SMTPIgnored = smtpIgnored
|
||||
|
||||
if latestVersionCache != "" {
|
||||
info.LatestVersion = latestVersionCache
|
||||
} else {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
latestVersionCache = latest
|
||||
if config.DisableVersionCheck {
|
||||
info.LatestVersion = "disabled"
|
||||
} else if detectLatestVersion {
|
||||
mu.RLock()
|
||||
cacheValid := time.Now().Before(vCache.expiry)
|
||||
cacheValue := vCache.value
|
||||
mu.RUnlock()
|
||||
|
||||
// clear latest version cache after 5 minutes
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
latestVersionCache = ""
|
||||
}()
|
||||
if cacheValid {
|
||||
info.LatestVersion = cacheValue
|
||||
} else {
|
||||
mu.Lock()
|
||||
// Re-check after acquiring write lock in case another goroutine refreshed it
|
||||
if time.Now().Before(vCache.expiry) {
|
||||
info.LatestVersion = vCache.value
|
||||
} else {
|
||||
latest, err := config.GHRUConfig.Latest()
|
||||
if err == nil {
|
||||
vCache = versionCache{value: latest.Tag, expiry: time.Now().Add(15 * time.Minute)}
|
||||
info.LatestVersion = latest.Tag
|
||||
} else {
|
||||
logger.Log().Errorf("Failed to fetch latest version: %v", err)
|
||||
vCache.errCount++
|
||||
vCache.value = ""
|
||||
vCache.expiry = time.Now().Add(getBackoff(vCache.errCount))
|
||||
info.LatestVersion = ""
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +147,7 @@ func Track() {
|
||||
func LogSMTPAccepted(size int) {
|
||||
mu.Lock()
|
||||
smtpAccepted = smtpAccepted + 1
|
||||
smtpAcceptedSize = smtpAcceptedSize + float64(size)
|
||||
smtpAcceptedSize = smtpAcceptedSize + tools.SafeUint64(size)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func dbCron() {
|
||||
if total == 0 {
|
||||
deletedPercent = 100
|
||||
} else {
|
||||
deletedPercent = deletedSize * 100 / total
|
||||
deletedPercent = float64(deletedSize * 100 / total)
|
||||
}
|
||||
// only vacuum the DB if at least 1% of mail storage size has been deleted
|
||||
if deletedPercent >= 1 {
|
||||
@@ -56,24 +56,23 @@ func pruneMessages() {
|
||||
start := time.Now()
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
var prunedSize uint64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
total := CountTotal()
|
||||
if total > float64(config.MaxAgeInHours) {
|
||||
offset := config.MaxMessages
|
||||
if config.DemoMode {
|
||||
offset = 500
|
||||
}
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(offset)
|
||||
if config.MaxMessages > 0 && CountTotal() > uint64(config.MaxMessages) {
|
||||
offset := config.MaxMessages
|
||||
if config.DemoMode {
|
||||
offset = 500
|
||||
}
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(offset)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
if err := q.QueryAndClose(
|
||||
context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
@@ -81,12 +80,12 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
},
|
||||
); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +109,7 @@ func pruneMessages() {
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
|
||||
@@ -27,12 +27,11 @@ import (
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
sqlDriver string
|
||||
dbLastAction time.Time
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
dbEncoder *zstd.Encoder
|
||||
dbDecoder, _ = zstd.NewReader(nil)
|
||||
|
||||
temporaryFiles = []string{}
|
||||
@@ -40,11 +39,31 @@ var (
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
// dbEncoder
|
||||
var (
|
||||
dsn string
|
||||
err error
|
||||
)
|
||||
|
||||
if config.Compression > 0 {
|
||||
var compression zstd.EncoderLevel
|
||||
switch config.Compression {
|
||||
case 1:
|
||||
compression = zstd.SpeedFastest
|
||||
case 2:
|
||||
compression = zstd.SpeedDefault
|
||||
case 3:
|
||||
compression = zstd.SpeedBestCompression
|
||||
}
|
||||
dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Log().Debugf("[db] storing messages with compression: %s", compression.String())
|
||||
} else {
|
||||
logger.Log().Debug("[db] storing messages with no compression")
|
||||
}
|
||||
|
||||
p := config.Database
|
||||
|
||||
if p == "" {
|
||||
@@ -100,8 +119,13 @@ func InitDB() error {
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if sqlDriver == "sqlite" {
|
||||
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
|
||||
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
|
||||
if config.DisableWAL {
|
||||
// disable WAL mode for SQLite, allows NFS mounted DBs
|
||||
_, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;")
|
||||
} else {
|
||||
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
|
||||
_, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -114,7 +138,6 @@ func InitDB() error {
|
||||
|
||||
LoadTagFilters()
|
||||
|
||||
dbFile = p
|
||||
dbLastAction = time.Now()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
@@ -185,65 +208,51 @@ func StatsGet() MailboxStats {
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() float64 {
|
||||
var total float64
|
||||
func CountTotal() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() float64 {
|
||||
var total float64
|
||||
func CountUnread() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() float64 {
|
||||
var total float64
|
||||
func CountRead() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// DbSize returns the size of the SQLite database.
|
||||
func DbSize() float64 {
|
||||
var total sql.NullFloat64
|
||||
func DbSize() uint64 {
|
||||
var total sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
|
||||
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
// IsUnread returns whether a message is unread or not.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&unread).
|
||||
Where("Read = ?", 0).
|
||||
Where("ID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return unread == 1
|
||||
return uint64(total.Float64)
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
|
||||
@@ -59,11 +59,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if float64(total) != s.Total {
|
||||
if uint64(total) != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
|
||||
}
|
||||
|
||||
if float64(unread) != s.Unread {
|
||||
if uint64(unread) != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package storage
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5" // #nosec
|
||||
"crypto/sha1" // #nosec
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -19,14 +22,15 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// Store will save an email to the database tables.
|
||||
// The username is the authentication username of either the SMTP or HTTP client (blank for none).
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
func Store(body *[]byte, username *string) (string, error) {
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
// Parse message body with enmime
|
||||
@@ -44,13 +48,16 @@ func Store(body *[]byte) (string, error) {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
obj := DBMailSummary{
|
||||
obj := Metadata{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
if username != nil {
|
||||
obj.Username = *username
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
@@ -82,17 +89,17 @@ func Store(body *[]byte) (string, error) {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := float64(len(*body))
|
||||
size := uint64(len(*body))
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
sql := fmt.Sprintf(`INSERT INTO %s
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
tenant("mailbox"),
|
||||
) // #nosec
|
||||
|
||||
@@ -102,10 +109,23 @@ func Store(body *[]byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert compressed raw message
|
||||
encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
|
||||
hexStr := hex.EncodeToString(encoded)
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
|
||||
if config.Compression > 0 {
|
||||
// insert compressed raw message
|
||||
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size))
|
||||
|
||||
if sqlDriver == "rqlite" {
|
||||
// rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal
|
||||
// string and then generate the SQL query, which is more memory intensive, especially with large messages
|
||||
hexStr := hex.EncodeToString(compressed)
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant("mailbox_data"), hexStr), id) // #nosec
|
||||
} else {
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec
|
||||
}
|
||||
} else {
|
||||
// insert uncompressed raw message
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant("mailbox_data")), id, string(*body)) // #nosec
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -130,6 +150,11 @@ func Store(body *[]byte) (string, error) {
|
||||
tags = append(tags, obj.tagsFromPlusAddresses()...)
|
||||
}
|
||||
|
||||
// auto-tag by username if enabled
|
||||
if config.TagsUsername && username != nil && *username != "" {
|
||||
tags = append(tags, *username)
|
||||
}
|
||||
|
||||
// extract tags from search matches, and sort and extract unique tags
|
||||
tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))
|
||||
|
||||
@@ -146,6 +171,24 @@ func Store(body *[]byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// we do not want to to broadcast null values for MetaData else this does not align
|
||||
// with the message summary documented in the API docs, so we set them to empty slices.
|
||||
if c.From == nil {
|
||||
c.From = &mail.Address{}
|
||||
}
|
||||
if c.To == nil {
|
||||
c.To = []*mail.Address{}
|
||||
}
|
||||
if c.Cc == nil {
|
||||
c.Cc = []*mail.Address{}
|
||||
}
|
||||
if c.Bcc == nil {
|
||||
c.Bcc = []*mail.Address{}
|
||||
}
|
||||
if c.ReplyTo == nil {
|
||||
c.ReplyTo = []*mail.Address{}
|
||||
}
|
||||
|
||||
c.Created = created
|
||||
c.ID = id
|
||||
c.MessageID = messageID
|
||||
@@ -162,7 +205,7 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, size)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
@@ -175,41 +218,52 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
|
||||
q := sqlf.From(tenant("mailbox") + " m").
|
||||
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
|
||||
OrderBy("m.Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
OrderBy("m.Created DESC")
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(start)
|
||||
}
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var metadataJSON string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
em := MessageSummary{}
|
||||
var meta Metadata
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
|
||||
err := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
em.From = meta.From
|
||||
em.To = meta.To
|
||||
em.Cc = meta.Cc
|
||||
em.Bcc = meta.Bcc
|
||||
em.ReplyTo = meta.ReplyTo
|
||||
em.Username = meta.Username
|
||||
|
||||
em.Created = time.UnixMilli(int64(created))
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -254,12 +308,20 @@ func GetMessage(id string) (*Message, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var from *mail.Address
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
// Load metadata from DB
|
||||
meta, err := GetMetadata(id)
|
||||
if err != nil {
|
||||
meta = Metadata{}
|
||||
}
|
||||
|
||||
from := meta.From
|
||||
if from == nil {
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
@@ -277,7 +339,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -285,7 +347,6 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(int64(created))
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -304,10 +365,10 @@ func GetMessage(id string) (*Message, error) {
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: float64(len(raw)),
|
||||
Size: uint64(len(raw)),
|
||||
Text: env.Text,
|
||||
Username: meta.Username,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
@@ -345,7 +406,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
if err := MarkRead([]string{id}); err != nil {
|
||||
return &obj, err
|
||||
}
|
||||
|
||||
@@ -356,11 +417,12 @@ func GetMessage(id string) (*Message, error) {
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(id string) ([]byte, error) {
|
||||
var i string
|
||||
var msg string
|
||||
var i, msg string
|
||||
var compressed int
|
||||
q := sqlf.From(tenant("mailbox_data")).
|
||||
Select(`ID`).To(&i).
|
||||
Select(`Email`).To(&msg).
|
||||
Select(`Compressed`).To(&compressed).
|
||||
Where(`ID = ?`, id)
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
@@ -372,7 +434,7 @@ func GetMessageRaw(id string) ([]byte, error) {
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if sqlDriver == "rqlite" {
|
||||
if sqlDriver == "rqlite" && compressed == 1 {
|
||||
data, err = base64.StdEncoding.DecodeString(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding base64 message: %w", err)
|
||||
@@ -381,14 +443,18 @@ func GetMessageRaw(id string) ([]byte, error) {
|
||||
data = []byte(msg)
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll(data, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return raw, err
|
||||
if compressed == 1 {
|
||||
raw, err := dbDecoder.DecodeAll(data, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
@@ -440,7 +506,15 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
o.Size = uint64(len(a.Content))
|
||||
|
||||
md5Hash := md5.Sum(a.Content) // #nosec
|
||||
sha1Hash := sha1.Sum(a.Content) // #nosec
|
||||
sha256Hash := sha256.Sum256(a.Content)
|
||||
|
||||
o.Checksums.MD5 = hex.EncodeToString(md5Hash[:])
|
||||
o.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:])
|
||||
o.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:])
|
||||
|
||||
return o
|
||||
}
|
||||
@@ -473,30 +547,28 @@ func LatestID(r *http.Request) (string, error) {
|
||||
}
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(id string) error {
|
||||
if !IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
func MarkRead(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllRead will mark all messages as read
|
||||
@@ -550,32 +622,30 @@ func MarkAllUnread() error {
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
func MarkUnread(ids []string) error {
|
||||
for _, id := range ids {
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
}
|
||||
|
||||
_, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessages deletes one or more messages in bulk
|
||||
@@ -596,19 +666,21 @@ func DeleteMessages(ids []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
toDelete := []string{}
|
||||
var totalSize float64
|
||||
var totalSize uint64
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := rows.Scan(&id, &size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toDelete = append(toDelete, id)
|
||||
totalSize = totalSize + size
|
||||
totalSize = totalSize + uint64(size)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -645,7 +717,7 @@ func DeleteMessages(ids []string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
addDeletedSize(totalSize)
|
||||
|
||||
logMessagesDeleted(len(toDelete))
|
||||
|
||||
@@ -693,7 +765,7 @@ func DeleteAllMessages() error {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
|
||||
|
||||
@@ -727,3 +799,17 @@ func DeleteAllMessages() error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetMetadata retrieves the metadata for a message by its ID
|
||||
func GetMetadata(id string) (Metadata, error) {
|
||||
var metadataJSON string
|
||||
row := db.QueryRow(fmt.Sprintf("SELECT Metadata FROM %s WHERE ID = ?", tenant("mailbox")), id)
|
||||
if err := row.Scan(&metadataJSON); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
var meta Metadata
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,13 +17,13 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -32,7 +33,7 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -54,13 +55,13 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -70,7 +71,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -79,56 +80,64 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
compressionLevels := []int{0, 1, 2, 3}
|
||||
|
||||
setup(tenantID)
|
||||
for _, compressionLevel := range compressionLevels {
|
||||
t.Logf("Testing compression level: %d", compressionLevel)
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
config.Compression = compressionLevel
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, uint64(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, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
// reset compression
|
||||
config.Compression = 1
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
@@ -143,7 +152,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
t.Logf("Testing message summary (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -177,7 +186,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
@@ -189,10 +198,106 @@ func BenchmarkImportMime(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestInlineImageContentIdHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing inline content handling")
|
||||
// Test case: Proper inline image with Content-Disposition: inline
|
||||
inlineAttachment, err := os.ReadFile("testdata/inline-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(&inlineAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 1:", err)
|
||||
}
|
||||
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 1:", err)
|
||||
}
|
||||
// Assert
|
||||
if len(msg.Inline) != 1 {
|
||||
t.Errorf("Test case 1: Expected 1 inline attachment, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 0 {
|
||||
t.Errorf("Test case 1: Expected 0 regular attachments, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Inline[0].ContentID != "test1@example.com" {
|
||||
t.Errorf("Test case 1: Expected ContentID 'test1@example.com', got '%s'", msg.Inline[0].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegularAttachmentHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing regular attachment handling")
|
||||
// Test case: Regular attachment without Content-ID
|
||||
regularAttachment, err := os.ReadFile("testdata/regular-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(®ularAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 3:", err)
|
||||
}
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 3:", err)
|
||||
}
|
||||
// Assert
|
||||
if len(msg.Inline) != 0 {
|
||||
t.Errorf("Test case 3: Expected 0 inline attachments, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 1 {
|
||||
t.Errorf("Test case 3: Expected 1 regular attachment, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Attachments[0].ContentID != "" {
|
||||
t.Errorf("Test case 3: Expected empty ContentID, got '%s'", msg.Attachments[0].ContentID)
|
||||
}
|
||||
|
||||
// Checksum tests
|
||||
assertEqual(t, msg.Attachments[0].Checksums.MD5, "b04930eb1ba0c62066adfa87e5d262c4", "Attachment MD5 checksum does not match")
|
||||
assertEqual(t, msg.Attachments[0].Checksums.SHA1, "15605d6a2fca44e966209d1701f16ecf816df880", "Attachment SHA1 checksum does not match")
|
||||
assertEqual(t, msg.Attachments[0].Checksums.SHA256, "92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec", "Attachment SHA256 checksum does not match")
|
||||
}
|
||||
|
||||
func TestMixedAttachmentHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing mixed attachment handling")
|
||||
// Mixed scenario with both inline and regular attachment
|
||||
mixedAttachment, err := os.ReadFile("testdata/mixed-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(&mixedAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 4:", err)
|
||||
}
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 4:", err)
|
||||
}
|
||||
// Assert: Should have 1 inline (with ContentID) and 1 attachment (without ContentID)
|
||||
if len(msg.Inline) != 1 {
|
||||
t.Errorf("Test case 4: Expected 1 inline attachment, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 1 {
|
||||
t.Errorf("Test case 4: Expected 1 regular attachment, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Inline[0].ContentID != "inline@example.com" {
|
||||
t.Errorf("Test case 4: Expected inline ContentID 'inline@example.com', got '%s'", msg.Inline[0].ContentID)
|
||||
}
|
||||
if msg.Attachments[0].ContentID != "" {
|
||||
t.Errorf("Test case 4: Expected attachment ContentID to be empty, got '%s'", msg.Attachments[0].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ func ReindexAll() {
|
||||
err := sqlf.Select("ID").To(&i).
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
ids = append(ids, i)
|
||||
})
|
||||
|
||||
@@ -73,23 +73,22 @@ func ReindexAll() {
|
||||
continue
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
meta, _ := GetMetadata(id)
|
||||
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
meta.From = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
meta.From = &mail.Address{Name: env.GetHeader("From")}
|
||||
} else {
|
||||
meta.From = nil
|
||||
}
|
||||
meta.To = addressToSlice(env, "To")
|
||||
meta.Cc = addressToSlice(env, "Cc")
|
||||
meta.Bcc = addressToSlice(env, "Bcc")
|
||||
meta.ReplyTo = addressToSlice(env, "Reply-To")
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
MetadataJSON, err := json.Marshal(obj)
|
||||
MetadataJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
@@ -115,7 +114,7 @@ func ReindexAll() {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// insert mail summary data
|
||||
for _, u := range updates {
|
||||
|
||||
@@ -2,20 +2,15 @@ package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
//go:embed schemas/*
|
||||
@@ -63,13 +58,6 @@ func dbApplySchemas() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete legacy migration database after 01/10/2024
|
||||
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schemaFiles, err := schemaScripts.ReadDir("schemas")
|
||||
@@ -161,64 +149,4 @@ func dataMigrations() {
|
||||
if SettingGet("DeletedSize") == "" {
|
||||
_ = SettingPut("DeletedSize", "0")
|
||||
}
|
||||
|
||||
migrateTagsToManyMany()
|
||||
}
|
||||
|
||||
// Migrate tags to ManyMany structure
|
||||
// Migration task implemented 12/2023
|
||||
// TODO: Can be removed end 06/2024 and Tags column & index dropped from mailbox
|
||||
func migrateTagsToManyMany() {
|
||||
toConvert := make(map[string][]string)
|
||||
q := sqlf.
|
||||
Select("ID, Tags").
|
||||
From(tenant("mailbox")).
|
||||
Where("Tags != ?", "[]").
|
||||
Where("Tags IS NOT NULL")
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var jsonTags string
|
||||
if err := row.Scan(&id, &jsonTags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tags := []string{}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
toConvert[id] = tags
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
|
||||
if len(toConvert) > 0 {
|
||||
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
|
||||
for id, tags := range toConvert {
|
||||
if _, err := SetMessageTags(id, tags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
} else {
|
||||
if _, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Tags", nil).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Info("[migration] tags conversion complete")
|
||||
}
|
||||
|
||||
// set all legacy `[]` tags to NULL
|
||||
if _, err := sqlf.Update(tenant("mailbox")).
|
||||
Set("Tags", nil).
|
||||
Where("Tags = ?", "[]").
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
6
internal/storage/schemas/1.21.2.sql
Normal file
6
internal/storage/schemas/1.21.2.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- DROP LEGACY MIGRATION TABLE
|
||||
DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }};
|
||||
|
||||
-- DROP LEGACY TAGS COLUMN
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_tags" }};
|
||||
ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags;
|
||||
22
internal/storage/schemas/1.21.8.sql
Normal file
22
internal/storage/schemas/1.21.8.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Rebuild message_tags to remove FOREIGN KEY REFERENCES
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }};
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }};
|
||||
|
||||
ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
TagID INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID);
|
||||
|
||||
INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old;
|
||||
|
||||
DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old;
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
5
internal/storage/schemas/1.23.0.sql
Normal file
5
internal/storage/schemas/1.23.0.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- CREATE Compressed COLUMN IN mailbox_data
|
||||
ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';
|
||||
|
||||
-- SET Compressed = 1 for all existing data
|
||||
UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1;
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -36,12 +39,12 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
@@ -62,7 +65,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -97,6 +100,39 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
// SearchUnreadCount returns the number of unread messages matching a search.
|
||||
// This is run one at a time to allow connected browsers to be updated.
|
||||
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
tsStart := time.Now()
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var unread float64 // use float64 for rqlite compatibility
|
||||
|
||||
q = q.Where("Read = 0").Select(`COUNT(*)`)
|
||||
|
||||
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var ignore sql.NullString
|
||||
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed)
|
||||
|
||||
return int64(unread), err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
@@ -105,15 +141,15 @@ func DeleteSearch(search, timezone string) error {
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
ids := []string{}
|
||||
deleteSize := float64(0)
|
||||
deleteSize := uint64(0)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -125,7 +161,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
deleteSize = deleteSize + size
|
||||
deleteSize = deleteSize + uint64(size)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -157,7 +193,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, ids := range chunks {
|
||||
delIDs := make([]interface{}, len(ids))
|
||||
@@ -187,19 +223,31 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
}
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
addDeletedSize(int64(deleteSize))
|
||||
// broadcast changes
|
||||
if len(ids) > 200 {
|
||||
websockets.Broadcast("prune", nil)
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
d := struct {
|
||||
ID string
|
||||
}{ID: id}
|
||||
websockets.Broadcast("delete", d)
|
||||
}
|
||||
}
|
||||
|
||||
addDeletedSize(deleteSize)
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
@@ -209,6 +257,47 @@ func DeleteSearch(search, timezone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSearchReadStatus marks all messages matching the search as read or unread
|
||||
func SetSearchReadStatus(search, timezone string, read bool) error {
|
||||
q := searchQueryBuilder(search, timezone).Where("Read = ?", !read)
|
||||
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if read {
|
||||
if err := MarkRead(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := MarkUnread(ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
@@ -250,8 +339,8 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
lw = lw[1:]
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||
if !re.MatchString(w) {
|
||||
// ignore blank searches
|
||||
if len(w) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -355,6 +444,12 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
}
|
||||
} else if lw == "has:inline" || lw == "has:inlines" {
|
||||
if exclude {
|
||||
q.Where("Inline = 0")
|
||||
} else {
|
||||
q.Where("Inline > 0")
|
||||
}
|
||||
} else if lw == "has:attachment" || lw == "has:attachments" {
|
||||
if exclude {
|
||||
q.Where("Attachments = 0")
|
||||
@@ -391,6 +486,22 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "larger:") && sizeToBytes(cleanString(w[7:])) > 0 {
|
||||
w = cleanString(w[7:])
|
||||
size := sizeToBytes(w)
|
||||
if exclude {
|
||||
q.Where("Size < ?", size)
|
||||
} else {
|
||||
q.Where("Size > ?", size)
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "smaller:") && sizeToBytes(cleanString(w[8:])) > 0 {
|
||||
w = cleanString(w[8:])
|
||||
size := sizeToBytes(w)
|
||||
if exclude {
|
||||
q.Where("Size > ?", size)
|
||||
} else {
|
||||
q.Where("Size < ?", size)
|
||||
}
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
@@ -403,3 +514,39 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m.
|
||||
//
|
||||
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
|
||||
// M, m, Mb, MB and mb are treated as Megabytes.
|
||||
func sizeToBytes(v string) uint64 {
|
||||
v = strings.ToLower(v)
|
||||
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
|
||||
|
||||
m := re.FindAllStringSubmatch(v, -1)
|
||||
if len(m) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
val := fmt.Sprintf("%s%s", m[0][1], m[0][2])
|
||||
unit := m[0][3]
|
||||
|
||||
i, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if unit == "" {
|
||||
return uint64(i)
|
||||
}
|
||||
|
||||
if unit == "k" || unit == "kb" {
|
||||
return uint64(i * 1024)
|
||||
}
|
||||
|
||||
if unit == "m" || unit == "mb" {
|
||||
return uint64(i * 1024 * 1024)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
@@ -48,7 +48,7 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
if _, err := Store(&bufBytes, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -117,11 +117,11 @@ func TestSearchDelete100(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -201,3 +201,25 @@ func TestEscPercentChar(t *testing.T) {
|
||||
assertEqual(t, res, expected, "no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeToBytes(t *testing.T) {
|
||||
tests := map[string]uint64{}
|
||||
tests["1m"] = 1048576
|
||||
tests["1mb"] = 1048576
|
||||
tests["1 M"] = 1048576
|
||||
tests["1 MB"] = 1048576
|
||||
tests["1k"] = 1024
|
||||
tests["1kb"] = 1024
|
||||
tests["1 K"] = 1024
|
||||
tests["1 kB"] = 1024
|
||||
tests["1.5M"] = 1572864
|
||||
tests["1234567890"] = 1234567890
|
||||
tests["invalid"] = 0
|
||||
tests["1.2.3"] = 0
|
||||
tests["1.2.3M"] = 0
|
||||
|
||||
for search, expected := range tests {
|
||||
res := sizeToBytes(search)
|
||||
assertEqual(t, res, expected, "size does not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func SettingGet(k string) string {
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", k).
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return ""
|
||||
@@ -35,37 +35,37 @@ func SettingPut(k, v string) error {
|
||||
}
|
||||
|
||||
// The total deleted message size as an int64 value
|
||||
func getDeletedSize() float64 {
|
||||
var result sql.NullFloat64
|
||||
func getDeletedSize() uint64 {
|
||||
var result sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
err := sqlf.From(tenant("settings")).
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", "DeletedSize").
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() float64 {
|
||||
func totalMessagesSize() uint64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("mailbox")).
|
||||
Select("SUM(Size)").To(&result).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// AddDeletedSize will add the value to the DeletedSize setting
|
||||
func addDeletedSize(v int64) {
|
||||
func addDeletedSize(v uint64) {
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -28,25 +28,27 @@ type Message struct {
|
||||
// Message subject
|
||||
Subject string
|
||||
// List-Unsubscribe header information
|
||||
// swagger:ignore
|
||||
ListUnsubscribe ListUnsubscribe
|
||||
// Message date if set, else date received
|
||||
// Message RFC3339Nano date & time (if set), else date & time received
|
||||
// ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Date time.Time
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message body text
|
||||
Text string
|
||||
// Message body HTML
|
||||
HTML string
|
||||
// Message size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
// Inline message attachments
|
||||
Inline []Attachment
|
||||
// Message attachments
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
// Attachment struct for inline and attachments
|
||||
// Attachment struct for inline images and attachments
|
||||
//
|
||||
// swagger:model Attachment
|
||||
type Attachment struct {
|
||||
@@ -59,7 +61,16 @@ type Attachment struct {
|
||||
// Content ID
|
||||
ContentID string
|
||||
// Size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
// File checksums
|
||||
Checksums struct {
|
||||
// MD5 checksum hash of file
|
||||
MD5 string
|
||||
// SHA1 checksum hash of file
|
||||
SHA1 string
|
||||
// SHA256 checksum hash of file
|
||||
SHA256 string
|
||||
}
|
||||
}
|
||||
|
||||
// MessageSummary struct for frontend messages
|
||||
@@ -84,12 +95,14 @@ type MessageSummary struct {
|
||||
ReplyTo []*mail.Address
|
||||
// Email subject
|
||||
Subject string
|
||||
// Created time
|
||||
// Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Created time.Time
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message size in bytes (total)
|
||||
Size float64
|
||||
Size uint64
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
// Message snippet includes up to 250 characters
|
||||
@@ -98,18 +111,19 @@ type MessageSummary struct {
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
ReplyTo []*mail.Address
|
||||
// Metadata struct for storing message metadata
|
||||
type Metadata struct {
|
||||
From *mail.Address `json:"From,omitempty"`
|
||||
To []*mail.Address `json:"To,omitempty"`
|
||||
Cc []*mail.Address `json:"Cc,omitempty"`
|
||||
Bcc []*mail.Address `json:"Bcc,omitempty"`
|
||||
ReplyTo []*mail.Address `json:"ReplyTo,omitempty"`
|
||||
Username string `json:"Username,omitempty"`
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
@@ -117,10 +131,10 @@ type DBMailSummary struct {
|
||||
type ListUnsubscribe struct {
|
||||
// List-Unsubscribe header value
|
||||
Header string
|
||||
// Detected links, maximum one email and one HTTP(S)
|
||||
// Detected links, maximum one email and one HTTP(S) link
|
||||
Links []string
|
||||
// Validation errors if any
|
||||
// Validation errors (if any)
|
||||
Errors string
|
||||
// List-Unsubscribe-Post value if set
|
||||
// List-Unsubscribe-Post value (if set)
|
||||
HeaderPost string
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func LoadTagFilters() {
|
||||
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
|
||||
continue
|
||||
}
|
||||
if t.Tags == nil || len(t.Tags) == 0 {
|
||||
if len(t.Tags) == 0 {
|
||||
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,17 +138,6 @@ func deleteMessageTag(id, name string) error {
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func DeleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// GetAllTags returns all used tags
|
||||
func GetAllTags() []string {
|
||||
var tags = []string{}
|
||||
@@ -158,7 +147,7 @@ func GetAllTags() []string {
|
||||
Select(`DISTINCT Name`).
|
||||
From(tenant("tags")).To(&name).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -171,7 +160,7 @@ func GetAllTags() []string {
|
||||
func GetAllTagsCount() map[string]int64 {
|
||||
var tags = make(map[string]int64)
|
||||
var name string
|
||||
var total int64
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
@@ -180,8 +169,8 @@ func GetAllTagsCount() map[string]int64 {
|
||||
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
|
||||
GroupBy(tenant("message_tags.TagID")).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags[name] = int64(total)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -323,7 +312,7 @@ func findTagsInRawMessage(message *[]byte) []string {
|
||||
}
|
||||
|
||||
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
|
||||
func (d DBMailSummary) tagsFromPlusAddresses() []string {
|
||||
func (d Metadata) tagsFromPlusAddresses() []string {
|
||||
tags := []string{}
|
||||
for _, c := range d.To {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
|
||||
@@ -363,7 +352,7 @@ func getMessageTags(id string) []string {
|
||||
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
|
||||
Where(tenant("message_tags.ID")+` = ?`, id).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
@@ -24,7 +26,7 @@ func TestTags(t *testing.T) {
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -57,7 +59,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -83,7 +85,7 @@ func TestTags(t *testing.T) {
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -109,7 +111,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -124,7 +126,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
id, err = Store(&testTagEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -132,7 +134,7 @@ func TestTags(t *testing.T) {
|
||||
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -141,3 +143,59 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
func TestUsernameAutoTagging(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
username := "testuser"
|
||||
|
||||
t.Run("Auto-tagging enabled", func(t *testing.T) {
|
||||
config.TagsUsername = true
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auto-tagging disabled", func(t *testing.T) {
|
||||
config.TagsUsername = false
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
t.Errorf("Did not expect username '%s' in tags when disabled, got %v", username, msg.Tags)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func deleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal file
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test inline image proper
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="boundary123"
|
||||
|
||||
--boundary123
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><img src="cid:test1@example.com" alt="Test"/></body></html>
|
||||
|
||||
--boundary123
|
||||
Content-Type: image/png; name="test1.png"
|
||||
Content-Disposition: inline; filename="test1.png"
|
||||
Content-ID: <test1@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
|
||||
|
||||
--boundary123--
|
||||
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal file
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test mixed attachments
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="boundary111"
|
||||
|
||||
--boundary111
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><img src="cid:inline@example.com" alt="Inline"/><p>Document attached</p></body></html>
|
||||
|
||||
--boundary111
|
||||
Content-Type: image/png; name="inline.png"
|
||||
Content-Disposition: inline; filename="inline.png"
|
||||
Content-ID: <inline@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
|
||||
|
||||
--boundary111
|
||||
Content-Type: application/pdf; name="document.pdf"
|
||||
Content-Disposition: attachment; filename="document.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
|
||||
|
||||
--boundary111--
|
||||
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal file
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test regular attachment
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="boundary789"
|
||||
|
||||
--boundary789
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><p>Message with regular attachment</p></body></html>
|
||||
|
||||
--boundary789
|
||||
Content-Type: application/pdf; name="document.pdf"
|
||||
Content-Disposition: attachment; filename="document.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
|
||||
|
||||
--boundary789--
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// for stats to prevent import cycle
|
||||
mu sync.RWMutex
|
||||
// StatsDeleted for counting the number of messages deleted
|
||||
StatsDeleted float64
|
||||
StatsDeleted uint64
|
||||
)
|
||||
|
||||
// AddTempFile adds a file to the slice of files to delete on exit
|
||||
@@ -88,7 +89,7 @@ func cleanString(str string) string {
|
||||
// LogMessagesDeleted logs the number of messages deleted
|
||||
func logMessagesDeleted(n int) {
|
||||
mu.Lock()
|
||||
StatsDeleted = StatsDeleted + float64(n)
|
||||
StatsDeleted = StatsDeleted + tools.SafeUint64(n)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
23
internal/tools/fs.go
Normal file
23
internal/tools/fs.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func IsDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -48,7 +49,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] removed %s header", hdr)
|
||||
logger.Log().Debugf("[relay] removed %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(""), 1)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +58,10 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// UpdateMessageHeader scans a message for a header and updates its value if found.
|
||||
func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
// SetMessageHeader scans a message for a header and updates its value if found.
|
||||
// It does not consider multiple instances of the same header.
|
||||
// If not found it will add the header to the beginning of the message.
|
||||
func SetMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
@@ -89,10 +92,68 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] replaced %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
|
||||
return bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1), nil
|
||||
}
|
||||
|
||||
// no header, so add one to beginning
|
||||
return append([]byte(header+": "+value+"\r\n"), msg...), nil
|
||||
}
|
||||
|
||||
// OverrideFromHeader scans a message for the From header and replaces it with a different email address.
|
||||
func OverrideFromHeader(msg []byte, address string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.Header.Get("From") != "" {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta("From:"))
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
originalFrom := strings.TrimRight(string(hdr[5:]), "\r\n")
|
||||
|
||||
from, err := mail.ParseAddress(originalFrom)
|
||||
if err != nil {
|
||||
// error parsing the from address, so just replace the whole line
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+address+"\r\n"), 1)
|
||||
} else {
|
||||
originalFrom = from.Address
|
||||
// replace the from email, but keep the original name
|
||||
from.Address = address
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+from.String()+"\r\n"), 1)
|
||||
}
|
||||
|
||||
// insert the original From header as X-Original-From
|
||||
msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...)
|
||||
|
||||
logger.Log().Debugf("[relay] Replaced From email address with %s", address)
|
||||
}
|
||||
} else {
|
||||
// no From header, so add one
|
||||
msg = append([]byte("From: "+address+"\r\n"), msg...)
|
||||
logger.Log().Debugf("[relay] Added From email: %s", address)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
|
||||
@@ -17,3 +17,34 @@ func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("%s not found", key)
|
||||
}
|
||||
|
||||
// SetHTMLAttributeVal sets an attribute on a node.
|
||||
func SetHTMLAttributeVal(n *html.Node, key, val string) {
|
||||
for i := range n.Attr {
|
||||
a := &n.Attr[i]
|
||||
if a.Key == key {
|
||||
a.Val = val
|
||||
return
|
||||
}
|
||||
}
|
||||
n.Attr = append(n.Attr, html.Attribute{
|
||||
Key: key,
|
||||
Val: val,
|
||||
})
|
||||
}
|
||||
|
||||
// WalkHTML traverses the entire HTML tree and calls fn on each node.
|
||||
func WalkHTML(n *html.Node, fn func(*html.Node)) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fn(n)
|
||||
|
||||
// Each node has a pointer to its first child and next sibling. To traverse
|
||||
// all children of a node, we need to start from its first child and then
|
||||
// traverse the next sibling until nil.
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
WalkHTML(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func ListUnsubscribeParser(v string) ([]string, error) {
|
||||
comments := reComments.FindAllStringSubmatch(v, -1)
|
||||
for _, c := range comments {
|
||||
// strip comments
|
||||
v = strings.Replace(v, c[0], "", -1)
|
||||
v = strings.ReplaceAll(v, c[0], "")
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
|
||||
28
internal/tools/net.go
Normal file
28
internal/tools/net.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
|
||||
// IsLoopback — 127.0.0.0/8, ::1
|
||||
// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
||||
// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
|
||||
// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16
|
||||
// IsUnspecified — 0.0.0.0, ::
|
||||
// IsMulticast — 224.0.0.0/4, ff00::/8
|
||||
func IsInternalIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast()
|
||||
}
|
||||
|
||||
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
|
||||
func IsValidLinkURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != ""
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func CreateSnippet(text, html string) string {
|
||||
return data
|
||||
}
|
||||
|
||||
return data[0:limit] + "..."
|
||||
return truncate(data, limit) + "..."
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
@@ -37,8 +37,33 @@ func CreateSnippet(text, html string) string {
|
||||
return text
|
||||
}
|
||||
|
||||
return text[0:limit] + "..."
|
||||
return truncate(text, limit) + "..."
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Truncate a string allowing for multi-byte encoding.
|
||||
// Shamelessly borrowed from Tailscale.
|
||||
// See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go
|
||||
func truncate(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
|
||||
// Back up until we find the beginning of a UTF-8 encoding.
|
||||
for n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte
|
||||
n--
|
||||
}
|
||||
|
||||
// If we're at the beginning of a multi-byte encoding, back up one more to
|
||||
// skip it. It's possible the value was already complete, but it's simpler
|
||||
// if we only have to check in one direction.
|
||||
//
|
||||
// Otherwise, we have a single-byte code (0x00... or 0x01...).
|
||||
if n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding
|
||||
n--
|
||||
}
|
||||
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`)
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.@]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
@@ -19,14 +19,21 @@ var (
|
||||
TagsTitleCase bool
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
|
||||
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters.
|
||||
// If the tag is longer than 100 characters, it is truncated.
|
||||
func CleanTag(s string) string {
|
||||
return strings.TrimSpace(
|
||||
t := strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
|
||||
if len(t) > 100 {
|
||||
return t[:100]
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// SetTagCasing returns the slice of tags, title-casing if set
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestCleanTag(t *testing.T) {
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test -"
|
||||
tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -"
|
||||
tests["this_is-a test "] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a @ test"
|
||||
tests["this is a long tag title with more than 100 characters, which should get automatically truncated to 100 characters"] = "this is a long tag title with more than 100 characters which should get automatically truncated to 1"
|
||||
|
||||
for search, expected := range tests {
|
||||
res := CleanTag(search)
|
||||
|
||||
@@ -36,3 +36,22 @@ func Normalize(s string) string {
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// SafeUint64 converts an int or int64 to uint64, ensuring it does not exceed the maximum value for uint64.
|
||||
func SafeUint64(i any) uint64 {
|
||||
switch v := i.(type) {
|
||||
case int:
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
case int64:
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
default:
|
||||
// only accepts int or int64
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// TarGZExtract extracts a archive from the file inputFilePath.
|
||||
// It tries to create the directory structure outputFilePath contains if it doesn't exist.
|
||||
// It returns potential errors to be checked or nil if everything works.
|
||||
func TarGZExtract(inputFilePath, outputFilePath string) (err error) {
|
||||
outputFilePath = stripTrailingSlashes(outputFilePath)
|
||||
inputFilePath, outputFilePath, err = makeAbsolute(inputFilePath, outputFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
undoDir, err := mkdirAll(outputFilePath, 0750)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
undoDir()
|
||||
}
|
||||
}()
|
||||
|
||||
return extract(inputFilePath, outputFilePath)
|
||||
}
|
||||
|
||||
// Creates all directories with os.MakedirAll and returns a function to remove the first created directory so cleanup is possible.
|
||||
func mkdirAll(dirPath string, perm os.FileMode) (func(), error) {
|
||||
var undoDir string
|
||||
|
||||
for p := dirPath; ; p = filepath.Dir(p) {
|
||||
finfo, err := os.Stat(p)
|
||||
if err == nil {
|
||||
if finfo.IsDir() {
|
||||
break
|
||||
}
|
||||
|
||||
finfo, err = os.Lstat(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if finfo.IsDir() {
|
||||
break
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("mkdirAll (%s): %v", p, syscall.ENOTDIR)
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
undoDir = p
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if undoDir == "" {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirPath, perm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
if err := os.RemoveAll(undoDir); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove trailing slash if any.
|
||||
func stripTrailingSlashes(path string) string {
|
||||
if len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[0 : len(path)-1]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// Make input and output paths absolute.
|
||||
func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error) {
|
||||
inputFilePath, err := filepath.Abs(inputFilePath)
|
||||
if err == nil {
|
||||
outputFilePath, err = filepath.Abs(outputFilePath)
|
||||
}
|
||||
|
||||
return inputFilePath, outputFilePath, err
|
||||
}
|
||||
|
||||
// Extract the file in filePath to directory.
|
||||
func extract(filePath string, directory string) error {
|
||||
file, err := os.Open(filepath.Clean(filePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
fmt.Printf("Error closing file: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
gzipReader, err := gzip.NewReader(bufio.NewReader(file))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
|
||||
// Post extraction directory permissions & timestamps
|
||||
type DirInfo struct {
|
||||
Path string
|
||||
Header *tar.Header
|
||||
}
|
||||
|
||||
// slice to add all extracted directory info for post-processing
|
||||
postExtraction := []DirInfo{}
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileInfo := header.FileInfo()
|
||||
// paths could contain a '..', is used in a file system operations
|
||||
if strings.Contains(fileInfo.Name(), "..") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(directory, filepath.Dir(header.Name))
|
||||
filename := filepath.Join(dir, fileInfo.Name())
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
// create the directory 755 in case writing permissions prohibit writing before files added
|
||||
if err := os.MkdirAll(filename, 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set file ownership (if allowed)
|
||||
// Chtimes() && Chmod() only set after once extraction is complete
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
|
||||
// add directory info to slice to process afterwards
|
||||
postExtraction = append(postExtraction, DirInfo{filename, header})
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure parent directory exists (may not be included in tar)
|
||||
if !fileInfo.IsDir() && !isDir(dir) {
|
||||
err = os.MkdirAll(dir, 0750)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
n, err := tarReader.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
panic(err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
_, err = writer.Write(buffer[:n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = writer.Flush()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set file permissions, timestamps & uid/gid
|
||||
_ = os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
|
||||
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
}
|
||||
|
||||
if len(postExtraction) > 0 {
|
||||
for _, dir := range postExtraction {
|
||||
_ = os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime)
|
||||
_ = os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Unzip will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (src) to an output directory (dest).
|
||||
func Unzip(src string, dest string) ([]string, error) {
|
||||
|
||||
var filenames []string
|
||||
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
|
||||
// Store filename/path for returning and using later on
|
||||
fpath := filepath.Join(dest, filepath.Clean(f.Name))
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return filenames, fmt.Errorf("%s: illegal file path", fpath)
|
||||
}
|
||||
|
||||
filenames = append(filenames, fpath)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Make File
|
||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(filepath.Clean(fpath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc) // #nosec - file is streamed from zip to file
|
||||
|
||||
// Close the file without defer to close before next iteration of loop
|
||||
if err := outFile.Close(); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
if err := rc.Close(); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
// package Updater checks and downloads new versions
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
)
|
||||
|
||||
var (
|
||||
// AllowPrereleases defines whether pre-releases may be included
|
||||
AllowPrereleases = false
|
||||
|
||||
// temporary directory
|
||||
tempDir string
|
||||
)
|
||||
|
||||
// Releases struct for Github releases json
|
||||
type Releases []struct {
|
||||
Name string `json:"name"` // release name
|
||||
Tag string `json:"tag_name"` // release tag
|
||||
Prerelease bool `json:"prerelease"` // Github pre-release
|
||||
Assets []struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// Release struct contains the file data for downloadable release
|
||||
type Release struct {
|
||||
Name string
|
||||
Tag string
|
||||
URL string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// GithubLatest fetches the latest release info & returns release tag, filename & download url
|
||||
func GithubLatest(repo, name string) (string, string, string, error) {
|
||||
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
|
||||
|
||||
timeout := time.Duration(5 * time.Second)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", releaseURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
linkOS := runtime.GOOS
|
||||
linkArch := runtime.GOARCH
|
||||
linkExt := ".tar.gz"
|
||||
if linkOS == "windows" {
|
||||
// Windows uses .zip instead
|
||||
linkExt = ".zip"
|
||||
}
|
||||
|
||||
var allReleases = []Release{}
|
||||
|
||||
var releases Releases
|
||||
|
||||
if err := json.Unmarshal(body, &releases); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
archiveName := fmt.Sprintf("%s-%s-%s%s", name, linkOS, linkArch, linkExt)
|
||||
|
||||
// loop through releases
|
||||
for _, r := range releases {
|
||||
if !semver.IsValid(r.Tag) {
|
||||
// Invalid semversion, skip
|
||||
continue
|
||||
}
|
||||
|
||||
if !AllowPrereleases && (semver.Prerelease(r.Tag) != "" || r.Prerelease) {
|
||||
// we don't accept AllowPrereleases, skip
|
||||
continue
|
||||
}
|
||||
|
||||
for _, a := range r.Assets {
|
||||
if a.Name == archiveName {
|
||||
thisRelease := Release{a.Name, r.Tag, a.BrowserDownloadURL, a.Size}
|
||||
allReleases = append(allReleases, thisRelease)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allReleases) == 0 {
|
||||
// no releases with suitable assets found
|
||||
return "", "", "", fmt.Errorf("No binary releases found")
|
||||
}
|
||||
|
||||
var latestRelease = Release{}
|
||||
|
||||
for _, r := range allReleases {
|
||||
// detect the latest release
|
||||
if semver.Compare(r.Tag, latestRelease.Tag) == 1 {
|
||||
latestRelease = r
|
||||
}
|
||||
}
|
||||
|
||||
return latestRelease.Tag, latestRelease.Name, latestRelease.URL, nil
|
||||
}
|
||||
|
||||
// GreaterThan compares the current version to a different version
|
||||
// returning < 1 not upgradeable
|
||||
func GreaterThan(toVer, fromVer string) bool {
|
||||
return semver.Compare(toVer, fromVer) == 1
|
||||
}
|
||||
|
||||
// GithubUpdate the running binary with the latest release binary from Github
|
||||
func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
ver, filename, downloadURL, err := GithubLatest(repo, appName)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if ver == currentVersion {
|
||||
return "", fmt.Errorf("No new release found")
|
||||
}
|
||||
|
||||
if semver.Compare(ver, currentVersion) < 1 {
|
||||
return "", fmt.Errorf("No newer releases found (latest %s)", ver)
|
||||
}
|
||||
|
||||
tmpDir := getTempDir()
|
||||
|
||||
// outFile can be a tar.gz or a zip, depending on architecture
|
||||
outFile := filepath.Join(tmpDir, filename)
|
||||
|
||||
if err := downloadToFile(downloadURL, outFile); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newExec := filepath.Join(tmpDir, "mailpit")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := Unzip(outFile, tmpDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
newExec = filepath.Join(tmpDir, "mailpit.exe")
|
||||
} else {
|
||||
if err := TarGZExtract(outFile, tmpDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
err := os.Chmod(newExec, 0755) // #nosec
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the new binary is executable (mainly for inconsistent darwin builds)
|
||||
/* #nosec G204 */
|
||||
cmd := exec.Command(newExec, "-h")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// get the running binary
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = replaceFile(oldExec, newExec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
// DownloadToFile downloads a URL to a file
|
||||
func downloadToFile(url, fileName string) error {
|
||||
// Get the data
|
||||
resp, err := http.Get(url) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(filepath.Clean(fileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := out.Close(); err != nil {
|
||||
logger.Log().Errorf("error closing file: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceFile replaces one file with another.
|
||||
// Running files cannot be overwritten, so it has to be moved
|
||||
// and the new binary saved to the original path. This requires
|
||||
// read & write permissions to both the original file and directory.
|
||||
// Note, on Windows it is not possible to delete a running program,
|
||||
// so the old exe is renamed and moved to os.TempDir()
|
||||
func replaceFile(dst, src string) error {
|
||||
// open the source file for reading
|
||||
source, err := os.Open(filepath.Clean(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// destination directory eg: /usr/local/bin
|
||||
dstDir := filepath.Dir(dst)
|
||||
// binary filename
|
||||
binaryFilename := filepath.Base(dst)
|
||||
// old binary tmp name
|
||||
dstOld := fmt.Sprintf("%s.old", binaryFilename)
|
||||
// new binary tmp name
|
||||
dstNew := fmt.Sprintf("%s.new", binaryFilename)
|
||||
// absolute path of new tmp file
|
||||
newTmpAbs := filepath.Join(dstDir, dstNew)
|
||||
// absolute path of old tmp file
|
||||
oldTmpAbs := filepath.Join(dstDir, dstOld)
|
||||
|
||||
// get src permissions
|
||||
fi, _ := os.Stat(dst)
|
||||
srcPerms := fi.Mode().Perm()
|
||||
|
||||
// create the new file
|
||||
tmpNew, err := os.OpenFile(filepath.Clean(newTmpAbs), os.O_CREATE|os.O_RDWR, srcPerms) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy new binary to <binary>.new
|
||||
if _, err := io.Copy(tmpNew, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// close immediately else Windows has a fit
|
||||
if err := tmpNew.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := source.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the current executable to <binary>.old
|
||||
if err := os.Rename(dst, oldTmpAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the <binary>.new to current executable
|
||||
if err := os.Rename(newTmpAbs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the old binary
|
||||
if runtime.GOOS == "windows" {
|
||||
tmpDir := os.TempDir()
|
||||
delFile := filepath.Join(tmpDir, filepath.Base(oldTmpAbs))
|
||||
if err := os.Rename(oldTmpAbs, delFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(oldTmpAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// remove the src file
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
// GetTempDir will create & return a temporary directory if one has not been specified
|
||||
func getTempDir() string {
|
||||
if tempDir == "" {
|
||||
randBytes := make([]byte, 6)
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tempDir = filepath.Join(os.TempDir(), "updater-"+hex.EncodeToString(randBytes))
|
||||
}
|
||||
if err := mkDirIfNotExists(tempDir); err != nil {
|
||||
// need a better way to exit
|
||||
logger.Log().Errorf("error: %s", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
return tempDir
|
||||
}
|
||||
|
||||
// MkDirIfNotExists will create a directory if it doesn't exist
|
||||
func mkDirIfNotExists(path string) error {
|
||||
if !isDir(path) {
|
||||
return os.MkdirAll(path, os.ModePerm) // #nosec
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDir returns if 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
|
||||
}
|
||||
25
main.go
25
main.go
@@ -1,3 +1,4 @@
|
||||
// Package main is the entrypoint
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,25 +11,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
exec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// running directly
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked
|
||||
// if the command executable contains "send" in the name (eg: sendmail), then run the sendmail command
|
||||
if strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), "send") {
|
||||
sendmail.Run()
|
||||
} else {
|
||||
// else run mailpit
|
||||
cmd.Execute()
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
3959
package-lock.json
generated
3959
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user