mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 17:47:01 +00:00
Compare commits
619 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55bdd45247 | ||
|
|
0b3a5fc5d8 | ||
|
|
3e90391991 | ||
|
|
ae15cac727 | ||
|
|
1020f76bf8 | ||
|
|
42a1fe1510 | ||
|
|
628b7e7881 | ||
|
|
fe5de77253 | ||
|
|
36eef88885 | ||
|
|
737cff5a96 | ||
|
|
009a7deaa1 | ||
|
|
b6d5a8c182 | ||
|
|
10224e7c8b | ||
|
|
d2086922e5 | ||
|
|
3c744edd20 | ||
|
|
7ed522e596 | ||
|
|
26c6f9d965 | ||
|
|
76a261bf06 | ||
|
|
86a3bea300 | ||
|
|
5fa6b20a53 | ||
|
|
3ad62769a6 | ||
|
|
a63952aee6 | ||
|
|
de95910539 | ||
|
|
60a41ce3ca | ||
|
|
898b36ce0b | ||
|
|
b4a4d44492 | ||
|
|
64e4e4240a | ||
|
|
0477c6573f | ||
|
|
28ac6d2099 | ||
|
|
43a1dbe3f0 | ||
|
|
aa3f860540 | ||
|
|
f54a2187ac | ||
|
|
063eab2c6a | ||
|
|
b282e6663b | ||
|
|
df777c6e90 | ||
|
|
8c4b1ac445 | ||
|
|
309c56566c | ||
|
|
12d47a0f82 | ||
|
|
27d601294a | ||
|
|
98343714be | ||
|
|
930901c4ec | ||
|
|
446cae145f | ||
|
|
6a4e5fb03c | ||
|
|
8f0549c596 | ||
|
|
4a762c502e | ||
|
|
9af04f83a3 | ||
|
|
8e0c174bf3 | ||
|
|
b193851269 | ||
|
|
95e346f8af | ||
|
|
582f1f88b2 | ||
|
|
0d084cfa1d | ||
|
|
aa0af5de32 | ||
|
|
ee49149df9 | ||
|
|
e18c45d0b3 | ||
|
|
87a68f6a53 | ||
|
|
6d35b7bc82 | ||
|
|
6cf7cba6b7 | ||
|
|
9788a01617 | ||
|
|
f4923c34ae | ||
|
|
b2ce855774 | ||
|
|
d489675c42 | ||
|
|
2ebaaa0fb2 | ||
|
|
80eba20679 | ||
|
|
1757a0086e | ||
|
|
e265d7018e | ||
|
|
a37da776d7 | ||
|
|
5baa598453 | ||
|
|
9d4bbe82e3 | ||
|
|
69226e91b2 | ||
|
|
8646efc979 | ||
|
|
7c42540427 | ||
|
|
c695cd23f6 | ||
|
|
bc53a34029 | ||
|
|
270d5f534f | ||
|
|
6a34c449a2 | ||
|
|
1723497c5c | ||
|
|
57e856f941 | ||
|
|
72d780fe66 | ||
|
|
4768b7b08c | ||
|
|
d01fb4044e | ||
|
|
8dbc661cb7 | ||
|
|
bc4b028c39 | ||
|
|
7875160aa7 | ||
|
|
f0c77ac962 | ||
|
|
5dbc585fce | ||
|
|
63fd86499d | ||
|
|
6db28c5ef7 | ||
|
|
92390a0999 | ||
|
|
149bfa80c2 | ||
|
|
6d2fab1bc6 | ||
|
|
93a6107df2 | ||
|
|
8c3705cc5d | ||
|
|
6379af5604 | ||
|
|
103bd564ab | ||
|
|
86a4633d24 | ||
|
|
80fa989a32 | ||
|
|
66850633a1 | ||
|
|
6c7a1d1ea2 | ||
|
|
0998595690 | ||
|
|
677b00e29a | ||
|
|
ba8b4366ce | ||
|
|
24fb49d079 | ||
|
|
c8a2effac4 | ||
|
|
9f63010ca5 | ||
|
|
f8d514e9e3 | ||
|
|
1922651d41 | ||
|
|
7d2716ee17 | ||
|
|
4c1df6f61e | ||
|
|
be3979241f | ||
|
|
aeb3585f3e | ||
|
|
b8de57da27 | ||
|
|
56982798dc | ||
|
|
ac0e7163dd | ||
|
|
7638500c05 | ||
|
|
5d63e9be9e | ||
|
|
672d9b7c26 | ||
|
|
d9be8f86d7 | ||
|
|
e3e827b180 | ||
|
|
daf6e453df | ||
|
|
9cb2c26c6f | ||
|
|
0aa8ea3d51 | ||
|
|
e05b284c2c | ||
|
|
d39b65deb7 | ||
|
|
7b8faa8a28 | ||
|
|
ebb98c99c0 | ||
|
|
a726cf9922 | ||
|
|
5d146a23d7 | ||
|
|
a6c1bbc977 | ||
|
|
d020861559 | ||
|
|
7fd3291040 | ||
|
|
479c74500c | ||
|
|
6b6de59c47 | ||
|
|
a5de4e4f65 | ||
|
|
48f22cca1f | ||
|
|
7748846b88 | ||
|
|
497086cb65 | ||
|
|
42ecadab9e | ||
|
|
4cfde7f947 | ||
|
|
70b604e028 | ||
|
|
8c295d4754 | ||
|
|
a1c34b37e1 | ||
|
|
e37583073e | ||
|
|
4de830c490 | ||
|
|
22a4509b13 | ||
|
|
1ed06161a8 | ||
|
|
a7ee479f06 | ||
|
|
93e8884ef7 | ||
|
|
1c228cda56 | ||
|
|
119b3864b2 | ||
|
|
b9f035790d | ||
|
|
1260c2e6df | ||
|
|
3431f18a3f | ||
|
|
2fa5138b49 | ||
|
|
652fec0f64 | ||
|
|
f168e11b05 | ||
|
|
35e81e0336 | ||
|
|
7beed988e5 | ||
|
|
4eea79f0c8 | ||
|
|
39767e979c | ||
|
|
4e2f02ee0a | ||
|
|
5a04534314 | ||
|
|
6725a809d5 | ||
|
|
64a067cff9 | ||
|
|
58dbccc0a7 | ||
|
|
3ef320d277 | ||
|
|
18e95b699e | ||
|
|
fc89655b7f | ||
|
|
ff9a6ff491 | ||
|
|
adce75ab8f | ||
|
|
12903cae60 | ||
|
|
7f55511c82 | ||
|
|
309036fb6d | ||
|
|
48387c3a13 | ||
|
|
a2ab350aff | ||
|
|
c150f1ba50 | ||
|
|
48bec0c8f6 | ||
|
|
fef2628c3f | ||
|
|
e5888ede8b | ||
|
|
374a760b88 | ||
|
|
0fdfa13a38 | ||
|
|
b41df78c4f | ||
|
|
870e523c97 | ||
|
|
0b391b5c37 | ||
|
|
c01f473e79 | ||
|
|
3c27fd715b | ||
|
|
714596a13a | ||
|
|
9ae02daf1a | ||
|
|
b6750600cb | ||
|
|
78e871e9b3 | ||
|
|
8ff2a5cf6a | ||
|
|
4a88d1fc24 | ||
|
|
d4268b8ae1 | ||
|
|
1b47716f5f | ||
|
|
42e6d71415 | ||
|
|
cd5789dda2 | ||
|
|
cd2a9d433a | ||
|
|
fe0dfe41e7 | ||
|
|
bee3174c78 | ||
|
|
a3187d5499 | ||
|
|
dc7f047b9a | ||
|
|
f3bb522143 | ||
|
|
3a41d56cc6 | ||
|
|
db5d8f672a | ||
|
|
3d96b2cad0 | ||
|
|
34c1748f4b | ||
|
|
52120abefd | ||
|
|
086142e977 | ||
|
|
078f42f4ea | ||
|
|
df5ded49b8 | ||
|
|
3bd1eca2ab | ||
|
|
95b54ce8a4 | ||
|
|
eb3330939d | ||
|
|
50b5f8667a | ||
|
|
a121c08dc4 | ||
|
|
9ff9b783cc | ||
|
|
7f68ea407b | ||
|
|
9a8e7ebdf9 | ||
|
|
db7f2c1a5d | ||
|
|
2ac0b40ecf | ||
|
|
d1edbe73b4 | ||
|
|
24e23790ec | ||
|
|
bc8722d1cf | ||
|
|
b1e3e1f879 | ||
|
|
635714945e | ||
|
|
1200750111 | ||
|
|
9670c4e1d5 | ||
|
|
1e97e9e21f | ||
|
|
ca31524487 | ||
|
|
4800922f91 | ||
|
|
6884cf34fc | ||
|
|
3b75bf3fa3 | ||
|
|
b4a971f552 | ||
|
|
e77d0a750d | ||
|
|
bdf887389e | ||
|
|
fdc1b05545 | ||
|
|
316b5d7c66 | ||
|
|
4f13785174 | ||
|
|
c83acfb255 | ||
|
|
1e8f10732e | ||
|
|
40bced067e | ||
|
|
f2bce03e9e | ||
|
|
34b62bd08a | ||
|
|
9d64e53b93 | ||
|
|
16bc025fff | ||
|
|
14a61859f0 | ||
|
|
304a379c30 | ||
|
|
82b0829429 | ||
|
|
25c393d380 | ||
|
|
b66f1d0ae1 | ||
|
|
5f919cc9dd | ||
|
|
225a1e2e2a | ||
|
|
6dca57ba9b | ||
|
|
60ea473acb | ||
|
|
0d9b0cdc43 | ||
|
|
e843de6166 | ||
|
|
b6f2618b34 | ||
|
|
31c0a501e8 | ||
|
|
08288e904d | ||
|
|
dfb455c59c | ||
|
|
5e00013a8d | ||
|
|
c5a8836b7e | ||
|
|
ae73c721db | ||
|
|
9ae9104ca3 | ||
|
|
aa2dc4cf62 | ||
|
|
cffbd3f884 | ||
|
|
a05cc59800 | ||
|
|
924ad9b064 | ||
|
|
b63e9b465b | ||
|
|
124f1c2bde | ||
|
|
64461c17a1 | ||
|
|
0ff6b18b43 | ||
|
|
1a638cf8ea | ||
|
|
126fa66d58 | ||
|
|
1f95461651 | ||
|
|
176f128057 | ||
|
|
031b5697e4 | ||
|
|
19f51c8931 | ||
|
|
7c62dca14b | ||
|
|
584d94b8e7 | ||
|
|
23370eab0f | ||
|
|
4f5b5e2f02 | ||
|
|
def9602811 | ||
|
|
3d63a27458 | ||
|
|
389f248603 | ||
|
|
04462f76c6 | ||
|
|
2752a09ca7 | ||
|
|
8eed8d92e5 | ||
|
|
6a82dd0eb2 | ||
|
|
b5b0c173c3 | ||
|
|
9c8329a05c | ||
|
|
7c329b56f8 | ||
|
|
26a84bc257 | ||
|
|
d65de12714 | ||
|
|
5ed55e58e1 | ||
|
|
84d3384120 | ||
|
|
efc9c10f83 | ||
|
|
962af81653 | ||
|
|
7deddc3119 | ||
|
|
058bc31e28 | ||
|
|
8e84b96233 | ||
|
|
a8dddbaa7b | ||
|
|
8f9876a0a3 | ||
|
|
17ecdb6165 | ||
|
|
eba934c0e0 | ||
|
|
31885008ed | ||
|
|
c48da61097 | ||
|
|
c532870adc | ||
|
|
85291683b6 | ||
|
|
09399db612 | ||
|
|
ea753f6948 | ||
|
|
0f73f7d261 | ||
|
|
e188325ddd | ||
|
|
6ab6d5fa2d | ||
|
|
f6545b55a4 | ||
|
|
1b798c5514 | ||
|
|
f16b105d26 | ||
|
|
af7df617af | ||
|
|
4e6d8e5803 | ||
|
|
14d2715832 | ||
|
|
6d902293c1 | ||
|
|
b423c26537 | ||
|
|
75db0e2911 | ||
|
|
0f21f2e4b5 | ||
|
|
c4a695e627 | ||
|
|
62cf75f8fb | ||
|
|
5350e2eb08 | ||
|
|
3bb9f4162a | ||
|
|
2d07683a28 | ||
|
|
fc753677f6 | ||
|
|
ab0c91545a | ||
|
|
b6e1b68c90 | ||
|
|
182d32a2c8 | ||
|
|
169c476c56 | ||
|
|
57b0e1666f | ||
|
|
a9ce35b741 | ||
|
|
fb03fda9ea | ||
|
|
e2254a68ef | ||
|
|
755ff37cdc | ||
|
|
03f30b01bf | ||
|
|
27d49417d7 | ||
|
|
aeeb732681 | ||
|
|
73a92a3952 | ||
|
|
9cd81afe7c | ||
|
|
41270b956e | ||
|
|
dfad730b21 | ||
|
|
3d31ae7da4 | ||
|
|
f0723fb64a | ||
|
|
b905ba4ec5 | ||
|
|
7675cd162f | ||
|
|
dff5a605b4 | ||
|
|
3f3b8a6d97 | ||
|
|
fc595c031d | ||
|
|
a897004dc1 | ||
|
|
6917477533 | ||
|
|
eede2bff99 | ||
|
|
de0549e60a | ||
|
|
17caa21afd | ||
|
|
4656717046 | ||
|
|
72fdbb8364 | ||
|
|
37b4f1f566 | ||
|
|
464fbf818c | ||
|
|
6360a69ff6 | ||
|
|
054438b952 | ||
|
|
cb6085790b | ||
|
|
1bd0c6ac74 | ||
|
|
7cb46ba869 | ||
|
|
6efe99ffdf | ||
|
|
cc121e4b27 | ||
|
|
ee86260651 | ||
|
|
cab9f8a729 | ||
|
|
790fbe69fd | ||
|
|
51074a9d72 | ||
|
|
28b4f2d09d | ||
|
|
b6c1c180c9 | ||
|
|
264ad1bf9f | ||
|
|
7d63c75557 | ||
|
|
0c4c2881c8 | ||
|
|
56999e97e2 | ||
|
|
d238675011 | ||
|
|
fea3b0a422 | ||
|
|
24b1dfa040 | ||
|
|
ab73a4bcfb | ||
|
|
df3b27b5e0 | ||
|
|
52bf19a40c | ||
|
|
c1694f1a22 | ||
|
|
894da47eda | ||
|
|
1718ec00e5 | ||
|
|
70df34d071 | ||
|
|
d101ec045d | ||
|
|
a1d8840da2 | ||
|
|
ed1bb83bda | ||
|
|
4b2e8b0174 | ||
|
|
594c4817a4 | ||
|
|
47a556d05e | ||
|
|
e3e7c09e81 | ||
|
|
98a932ecdb | ||
|
|
d47eb09c54 | ||
|
|
acee53537c | ||
|
|
b18bcebd51 | ||
|
|
0502056678 | ||
|
|
6901a20661 | ||
|
|
10752a58c8 | ||
|
|
c8bf742c18 | ||
|
|
7313862ad5 | ||
|
|
8976124b3d | ||
|
|
4fbff688ec | ||
|
|
dca70a50c3 | ||
|
|
eb50304a13 | ||
|
|
858dfca321 | ||
|
|
5e09dec667 | ||
|
|
638ea3efa8 | ||
|
|
06bfc3b6e3 | ||
|
|
c2d34f3071 | ||
|
|
be582291c7 | ||
|
|
646fe072be | ||
|
|
deba47f6d1 | ||
|
|
5f9efebeb3 | ||
|
|
06aa7a2dea | ||
|
|
2c3c436fc1 | ||
|
|
6f2dd83936 | ||
|
|
b850c89ae0 | ||
|
|
cc327ab3ba | ||
|
|
1886d78001 | ||
|
|
63cbafa182 | ||
|
|
95dacfc5db | ||
|
|
067d218f4b | ||
|
|
3dd004ea4b | ||
|
|
6570217bfd | ||
|
|
54635b748a | ||
|
|
0ea4cab33b | ||
|
|
0fde942e0d | ||
|
|
b09d7ac75d | ||
|
|
fc2fdd20f6 | ||
|
|
cbbac40c0d | ||
|
|
6bc02fd4d4 | ||
|
|
57cfb2611c | ||
|
|
ba24d145ff | ||
|
|
376e799eb0 | ||
|
|
1dfadda07e | ||
|
|
fc0a7358ab | ||
|
|
d229b34d98 | ||
|
|
cbc3fe59a8 | ||
|
|
ab771cf76c | ||
|
|
7a27e09d23 | ||
|
|
cdce989a9c | ||
|
|
61dd3eddc5 | ||
|
|
290e48d875 | ||
|
|
e7ea94a5d2 | ||
|
|
43bd2a18ea | ||
|
|
ec95e58e13 | ||
|
|
70ac9c73ea | ||
|
|
0fcdcdd5f6 | ||
|
|
ea12a1ee56 | ||
|
|
9345ed60c6 | ||
|
|
0a13cf8304 | ||
|
|
4ebbdab7c0 | ||
|
|
cea9518b4b | ||
|
|
a9220277d6 | ||
|
|
bd45d9dffe | ||
|
|
baaf3a3a23 | ||
|
|
2e95a75d32 | ||
|
|
53d2296ff5 | ||
|
|
e8bf803ca0 | ||
|
|
d9dc000e89 | ||
|
|
205611856b | ||
|
|
5d396b9f25 | ||
|
|
4b95c6bda0 | ||
|
|
9982948c81 | ||
|
|
614b63cf28 | ||
|
|
b1027ca844 | ||
|
|
2176ad6ca2 | ||
|
|
971753e576 | ||
|
|
9053651cc1 | ||
|
|
a9593030ab | ||
|
|
75a7c1cfd4 | ||
|
|
699a534632 | ||
|
|
53f8d34961 | ||
|
|
81d09aabd1 | ||
|
|
11eec7db30 | ||
|
|
6e6482f6ad | ||
|
|
1efbbb353b | ||
|
|
b61fbe371a | ||
|
|
a2b6107dd6 | ||
|
|
f457412f98 | ||
|
|
14f1d75dba | ||
|
|
ce838dc054 | ||
|
|
0d29f3db1a | ||
|
|
cbc77530e9 | ||
|
|
70e8edf648 | ||
|
|
4368541a96 | ||
|
|
4d511bd29d | ||
|
|
b0894a8064 | ||
|
|
5d32d5190d | ||
|
|
b7154963c5 | ||
|
|
001e9de123 | ||
|
|
b64a5b7991 | ||
|
|
906a697542 | ||
|
|
46dbde04ae | ||
|
|
a31a7c3d2c | ||
|
|
675704ca91 | ||
|
|
d253d3164e | ||
|
|
ef3da383da | ||
|
|
db6c2596a0 | ||
|
|
7349d838bb | ||
|
|
d8c6364622 | ||
|
|
df758d063a | ||
|
|
34da0e5042 | ||
|
|
4a92b99a53 | ||
|
|
b1dc121cdd | ||
|
|
e5c8ef9e8d | ||
|
|
c6695c2418 | ||
|
|
53bbf4c7dc | ||
|
|
0015300920 | ||
|
|
fa6a5d729f | ||
|
|
cc9fba7adf | ||
|
|
93665656cf | ||
|
|
d918fdb137 | ||
|
|
fd1346c5f4 | ||
|
|
388bea740b | ||
|
|
583df9ee1f | ||
|
|
8f05b97947 | ||
|
|
8bdd0cc635 | ||
|
|
a372e8150e | ||
|
|
2bc2660ad5 | ||
|
|
5d6aa7c48a | ||
|
|
997e041042 | ||
|
|
5c362c1430 | ||
|
|
9219b2d411 | ||
|
|
86abc7ea68 | ||
|
|
867dbf41d5 | ||
|
|
51e458ad57 | ||
|
|
d29a7d6218 | ||
|
|
f6a8de3215 | ||
|
|
4e2e59ec87 | ||
|
|
6aeebb9824 | ||
|
|
a426f64795 | ||
|
|
b228c9477e | ||
|
|
d70f2fd196 | ||
|
|
0da89d91dd | ||
|
|
edab9e1b6b | ||
|
|
66aead387e | ||
|
|
efe1ac732e | ||
|
|
33dcd489eb | ||
|
|
6b2e5b2e41 | ||
|
|
812c9b99d1 | ||
|
|
8202c94a43 | ||
|
|
c1d4a73440 | ||
|
|
8e100ff21b | ||
|
|
088b772de5 | ||
|
|
faf8bd4a08 | ||
|
|
0e83a5a985 | ||
|
|
3ee91eb6c8 | ||
|
|
5cd0a6e2f3 | ||
|
|
fea733a43e | ||
|
|
d4e520772e | ||
|
|
e4a7212f89 | ||
|
|
e6a5fceedd | ||
|
|
bf4d5fbc6b | ||
|
|
93c3dec66e | ||
|
|
98026e0685 | ||
|
|
ecd3a97853 | ||
|
|
695270e515 | ||
|
|
43403bc6f7 | ||
|
|
6dbdbf1637 | ||
|
|
3c81e152e6 | ||
|
|
9501b460c5 | ||
|
|
6233cb1e07 | ||
|
|
f64f377199 | ||
|
|
f872424526 | ||
|
|
5d530edfab | ||
|
|
12c54f4bb3 | ||
|
|
23e47c567a | ||
|
|
b6940eccff | ||
|
|
eb796924b1 | ||
|
|
54ba59872e | ||
|
|
eff483c1c4 | ||
|
|
9f5d329105 | ||
|
|
77e6b88c5d | ||
|
|
5a9fd0686e | ||
|
|
3054dfe79e | ||
|
|
40cb76810e | ||
|
|
8b6b6640d5 | ||
|
|
a8945bd303 | ||
|
|
53e199b20f | ||
|
|
a6693481fa | ||
|
|
1aa58eeaaf | ||
|
|
133b36c34c | ||
|
|
ed28a4cc0d | ||
|
|
bc30b012cf | ||
|
|
2ae51c3f64 | ||
|
|
b6a87b9410 | ||
|
|
1f7dd0287a | ||
|
|
f33cbce63f | ||
|
|
79b6892320 | ||
|
|
799987ecb1 | ||
|
|
2d57839b3e | ||
|
|
86cc237c78 | ||
|
|
cc15ada304 | ||
|
|
49bc62f0aa | ||
|
|
444b65d371 | ||
|
|
15859f7be9 | ||
|
|
486388a798 | ||
|
|
9ab28d606a | ||
|
|
18b5ce8c18 | ||
|
|
93d5289d25 | ||
|
|
97bf9c257c | ||
|
|
18b0f5b790 | ||
|
|
94feb2ccaa | ||
|
|
aba3c46eb1 | ||
|
|
c9c910ab7c | ||
|
|
29c7295d16 | ||
|
|
61e15e4155 | ||
|
|
e03618570d | ||
|
|
d4cf95363f | ||
|
|
f260495495 | ||
|
|
d9f1f88107 | ||
|
|
09b704bcd7 | ||
|
|
a14cdce07f | ||
|
|
9fc5318e86 |
@@ -2,7 +2,6 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
|
||||
{{ if .Versions -}}
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
## [Unreleased]
|
||||
@@ -20,7 +19,17 @@ Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
## {{ .Tag.Name }}
|
||||
## [{{ .Tag.Name }}]
|
||||
|
||||
{{ if .NoteGroups -}}
|
||||
{{ range .NoteGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Notes }}
|
||||
{{ .Body }}
|
||||
{{ end -}}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
@@ -28,21 +37,11 @@ Notable changes to Mailpit will be documented in this file.
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
|
||||
{{- if .MergeCommits -}}
|
||||
### Pull Requests
|
||||
{{ range .MergeCommits -}}
|
||||
- {{ .Header }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
|
||||
{{- if .NoteGroups -}}
|
||||
{{ range .NoteGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Notes }}
|
||||
{{ .Body }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [axllent]
|
||||
23
.github/dependabot.yml
vendored
Normal file
23
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
23
.github/workflows/build-docker.yml
vendored
23
.github/workflows/build-docker.yml
vendored
@@ -10,10 +10,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get tag
|
||||
id: tag
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
@@ -26,12 +22,23 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
|
||||
- name: Parse semver
|
||||
id: semver_parser
|
||||
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
|
||||
with:
|
||||
input_string: '${{ github.ref_name }}'
|
||||
version_extractor_regex: 'v(.*)$'
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=${{ steps.tag.outputs.tag }}"
|
||||
"VERSION=${{ github.ref_name }}"
|
||||
push: true
|
||||
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}
|
||||
tags: |
|
||||
axllent/mailpit:latest
|
||||
axllent/mailpit:${{ github.ref_name }}
|
||||
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
|
||||
23
.github/workflows/close-stale-issues.yml
vendored
Normal file
23
.github/workflows/close-stale-issues.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Close stale issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v8.0.0
|
||||
with:
|
||||
days-before-issue-stale: 21
|
||||
days-before-issue-close: 7
|
||||
exempt-issue-labels: "enhancement,bug,javascript,docker"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 21 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
21
.github/workflows/release-build.yml
vendored
21
.github/workflows/release-build.yml
vendored
@@ -10,29 +10,30 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin]
|
||||
goarch: ["386", amd64, arm64]
|
||||
goarch: ["386", amd64, arm, arm64]
|
||||
exclude:
|
||||
- goarch: "386"
|
||||
goos: darwin
|
||||
- goarch: arm64
|
||||
- goarch: "386"
|
||||
goos: windows
|
||||
- goarch: arm
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Get tag
|
||||
id: tag
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: echo "Building assets for ${{ github.ref_name }}"
|
||||
- run: npm install
|
||||
- run: npm run package
|
||||
|
||||
# build the binaries
|
||||
- uses: wangyoucao577/go-release-action@v1.30
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
@@ -42,4 +43,6 @@ jobs:
|
||||
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
extra_files: LICENSE README.md
|
||||
md5sum: false
|
||||
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"
|
||||
overwrite: true
|
||||
retry: 5
|
||||
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
name: Test
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.18.x]
|
||||
go-version: [1.21.x]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- uses: actions/checkout@v3
|
||||
@@ -24,13 +24,13 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test ./storage -v
|
||||
- run: go test ./storage -bench=.
|
||||
- run: go test ./internal/storage ./server ./internal/tools -v
|
||||
- run: go test ./internal/storage -bench=.
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm run package
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
/node_modules/
|
||||
/send
|
||||
/sendmail/sendmail
|
||||
/server/ui/dist
|
||||
/Makefile
|
||||
/mailpit
|
||||
/mailpit*
|
||||
/.idea
|
||||
*.old
|
||||
*.db
|
||||
|
||||
795
CHANGELOG.md
795
CHANGELOG.md
@@ -2,8 +2,780 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.9.3]
|
||||
|
||||
## 0.1.2
|
||||
### Chore
|
||||
- Update internal/storage import paths
|
||||
- Move storage package to internal/storage
|
||||
- Update internal import paths
|
||||
- Move utils/* packages to internal/*
|
||||
|
||||
### Testing
|
||||
- Add endpoints for integration tests
|
||||
|
||||
### Tests
|
||||
- Add more API tests
|
||||
- Add tests for ArgsParser & CleanTag
|
||||
|
||||
### UI
|
||||
- Do not show excluded search tags as "current" in nav
|
||||
- Display "Loading messages" instead of "No results" while loading results
|
||||
- Only queue broadcast events if clients are connected
|
||||
|
||||
|
||||
## [v1.9.2]
|
||||
|
||||
### Fix
|
||||
- Delete all messages matching search when more than 1000 results
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
### Tests
|
||||
- Add message tag tests
|
||||
- Add search delete tests
|
||||
|
||||
### UI
|
||||
- Reset pagination when returning to inbox from search
|
||||
|
||||
|
||||
## [v1.9.1]
|
||||
|
||||
### Chore
|
||||
- Update caniemail data
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Set 404 page when loading a non-existent message
|
||||
- Link email addresses in message summary to search
|
||||
- Better support for mobile screen sizes
|
||||
|
||||
|
||||
## [v1.9.0]
|
||||
|
||||
### API
|
||||
- Remove redundant `Read` status from message (always true)
|
||||
- Delete by search filter
|
||||
- Add endpoint to return all tags in use
|
||||
|
||||
### Feature
|
||||
- Improved search parser
|
||||
- New search filter `[!]is:tagged`
|
||||
|
||||
### Fix
|
||||
- Correctly escape certain characters in search (eg: `'`)
|
||||
|
||||
### Libs
|
||||
- Update minimum Go version to 1.20
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### Tests
|
||||
- Bump Go version to 1.21
|
||||
|
||||
### UI
|
||||
- Rewrite web UI, add URL routing and components
|
||||
|
||||
|
||||
## [v1.8.4]
|
||||
|
||||
### Fix
|
||||
- Correctly decode proxy links containing HTML entities (screenshots)
|
||||
|
||||
|
||||
## [v1.8.3]
|
||||
|
||||
### Feature
|
||||
- HTML screenshots
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Group message tabs on mobile
|
||||
|
||||
|
||||
## [v1.8.2]
|
||||
|
||||
### Build
|
||||
- Update wangyoucao577/go-release-action[@v1](https://github.com/v1).39
|
||||
|
||||
### Feature
|
||||
- Link check to test message links
|
||||
- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
|
||||
|
||||
### Libs
|
||||
- Update Go libs
|
||||
|
||||
### UI
|
||||
- Set hostname in page meta title to identify Mailpit instance
|
||||
|
||||
|
||||
## [v1.8.1]
|
||||
|
||||
### Docs
|
||||
- Add pagination to swagger search documentation
|
||||
|
||||
### Fix
|
||||
- Check/set message Reply-To using SMTP FROM
|
||||
- Exclude "sendmail" from recipients list when using `mailpit sendmail <options>`
|
||||
- Exclude <script type="application/json"> from HTML check tests
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.8.0]
|
||||
|
||||
### Docs
|
||||
- Update brew installation instructions
|
||||
|
||||
### Feature
|
||||
- HTML check to test & score mail client compatibility with HTML emails
|
||||
|
||||
### Fix
|
||||
- Add basePath to swagger.json if webroot is specified
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger docs
|
||||
|
||||
### UI
|
||||
- Add flag to block all access to remote CSS and fonts (CSP)
|
||||
- Remove `<base />` tag if set in HTML preview
|
||||
- Pagination support for search, all results
|
||||
|
||||
|
||||
## [v1.7.1]
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Wrap HTML source lines
|
||||
- Dark mode color adjustments
|
||||
- Update dark mode loading background color
|
||||
|
||||
|
||||
## [v1.7.0]
|
||||
|
||||
### API
|
||||
- Ignore SMTP relay error when one of multiple recipients doesn't exist
|
||||
- Set raw message Content-Type to UTF-8
|
||||
|
||||
### Build
|
||||
- Define Vue build options in esbuild
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Theme toggler - auto, light and dark themes
|
||||
|
||||
|
||||
## [v1.6.22]
|
||||
|
||||
### Feature
|
||||
- Clearer SMTP error messages
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Upgrade node modules
|
||||
|
||||
|
||||
## [v1.6.21]
|
||||
|
||||
### UI
|
||||
- More accurate clickable hyperlink logic in plain text messages
|
||||
|
||||
|
||||
## [v1.6.20]
|
||||
|
||||
### Feature
|
||||
- Convert links into clickable hyperlinks in plain text message content
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.19]
|
||||
|
||||
### Fix
|
||||
- Only display sendmail help when sendmail subcommand is invoked
|
||||
|
||||
|
||||
## [v1.6.18]
|
||||
|
||||
### API
|
||||
- Sort tags before saving
|
||||
|
||||
### UI
|
||||
- Add option to enable tag colors based on tag name hash
|
||||
- Display message tags below subject in message overview
|
||||
|
||||
|
||||
## [v1.6.17]
|
||||
|
||||
### Fix
|
||||
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
|
||||
|
||||
|
||||
## [v1.6.16]
|
||||
|
||||
### Bugfix
|
||||
- Fix sendmail/startup panic
|
||||
|
||||
|
||||
## [v1.6.15]
|
||||
|
||||
### Feature
|
||||
- Add `sendmail -bs` functionality
|
||||
|
||||
|
||||
## [v1.6.14]
|
||||
|
||||
### Feature
|
||||
- Add ability to delete or mark search results read
|
||||
- Set tags via X-Tags message header
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.13]
|
||||
|
||||
### Feature
|
||||
- Add SMTP LOGIN authentication method for message relay
|
||||
|
||||
|
||||
## [v1.6.12]
|
||||
|
||||
### Feature
|
||||
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
|
||||
|
||||
### Swagger
|
||||
- Update swagger field descriptions, add MessageID
|
||||
|
||||
|
||||
## [v1.6.11]
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Check for secure context instead of HTTPS ([#114](https://github.com/axllent/mailpit/issues/114))
|
||||
|
||||
|
||||
## [v1.6.10]
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Remove "Noto Color Emoji" from default bootstrap font list
|
||||
|
||||
|
||||
## [v1.6.9]
|
||||
|
||||
### API
|
||||
- Return blank 200 response for OPTIONS requests (CORS)
|
||||
|
||||
### Bugfix
|
||||
- Correctly escape JS cid regex
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.6.8]
|
||||
|
||||
### Bugfix
|
||||
- Fix Date display when message doesn't contain a Date header
|
||||
|
||||
### Feature
|
||||
- Add allowlist to filter recipients before relaying messages ([#109](https://github.com/axllent/mailpit/issues/109))
|
||||
- Add `-S` short flag for sendmail `--smtp-addr`
|
||||
|
||||
|
||||
## [v1.6.7]
|
||||
|
||||
### Bugfix
|
||||
- Fix auto-deletion cron
|
||||
|
||||
|
||||
## [v1.6.6]
|
||||
|
||||
### API
|
||||
- Set Access-Control-Allow-Headers when --api-cors is set
|
||||
- Include correct start value in search reponse
|
||||
|
||||
### Feature
|
||||
- Option to ignore duplicate Message-IDs
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger field descriptions
|
||||
|
||||
### UI
|
||||
- Style Undisclosed recipients in message view
|
||||
|
||||
|
||||
## [v1.6.5]
|
||||
|
||||
### Feature
|
||||
- Add Access-Control-Allow-Methods methods when CORS origin is set
|
||||
|
||||
|
||||
## [v1.6.4]
|
||||
|
||||
### Bugfix
|
||||
- Fix UI images not displaying when multiple cid names overlap
|
||||
|
||||
|
||||
## [v1.6.3]
|
||||
|
||||
### Feature
|
||||
- Display clickable toast notifications for new messages
|
||||
|
||||
|
||||
## [v1.6.2]
|
||||
|
||||
### Bugfix
|
||||
- If set use return-path address as SMTP from address
|
||||
|
||||
|
||||
## [v1.6.1]
|
||||
|
||||
### Bugfix
|
||||
- Add API release route again (bad merge)
|
||||
|
||||
|
||||
## [v1.6.0]
|
||||
|
||||
### API
|
||||
- Enable cross-origin resource sharing (CORS) configuration
|
||||
- Message relay / release
|
||||
- Include Return-Path in message summary data
|
||||
|
||||
### Feature
|
||||
- Inject/update Bcc header for missing addresses when SMTP recipients do not match messsage headers
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Display Return-Path if different to the From address
|
||||
- Message release functionality
|
||||
|
||||
|
||||
## [v1.5.5]
|
||||
|
||||
### Docker
|
||||
- Add Docker image tag for major/minor version
|
||||
|
||||
### Feature
|
||||
- Update listen regex to allow IPv6 addresses ([#85](https://github.com/axllent/mailpit/issues/85))
|
||||
|
||||
|
||||
## [v1.5.4]
|
||||
|
||||
### Feature
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
|
||||
|
||||
## [v1.5.3]
|
||||
|
||||
### Bugfix
|
||||
- Enable SMTP auth flags to be set via env
|
||||
|
||||
|
||||
## [v1.5.2]
|
||||
|
||||
### API
|
||||
- Include Reply-To in message summary (including Web UI)
|
||||
|
||||
### UI
|
||||
- Tab to view formatted message headers
|
||||
|
||||
|
||||
## [v1.5.1]
|
||||
|
||||
### Feature
|
||||
- Add 'o', 'b' & 's' ignored flags for sendmail
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.5.0]
|
||||
|
||||
### API
|
||||
- Return received datetime when message does not contain a date header
|
||||
|
||||
### Bugfix
|
||||
- Fix JavaScript error when adding the first tag manually
|
||||
|
||||
### Feature
|
||||
- OpenAPI / Swagger schema
|
||||
- Download raw message, HTML/text body parts or attachments via single button
|
||||
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
|
||||
- Options to support auth without STARTTLS, and accept any login
|
||||
- Option to use message dates as received dates (new messages only)
|
||||
|
||||
|
||||
## [v1.4.0]
|
||||
|
||||
### API
|
||||
- Return received datetime when message does not contain a date header
|
||||
|
||||
### Feature
|
||||
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
|
||||
- Options to support auth without STARTTLS, and accept any login
|
||||
- Option to use message dates as received dates (new messages only)
|
||||
|
||||
|
||||
## [v1.3.11]
|
||||
|
||||
### Docker
|
||||
- Expose default ports (1025/tcp 8025/tcp)
|
||||
|
||||
### Feature
|
||||
- Expand custom webroot path to include a-z A-Z 0-9 _ . - and /
|
||||
|
||||
|
||||
## [v1.3.10]
|
||||
|
||||
### Bugfix
|
||||
- Fix search with existing emails
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.3.9]
|
||||
|
||||
### Feature
|
||||
- Add Cc and Bcc search filters
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Pull Requests
|
||||
- Merge pull request [#44](https://github.com/axllent/mailpit/issues/44) from axllent/dependabot/github_actions/wangyoucao577/go-release-action-1.36
|
||||
- Merge pull request [#43](https://github.com/axllent/mailpit/issues/43) from axllent/dependabot/github_actions/docker/build-push-action-4
|
||||
- Merge pull request [#55](https://github.com/axllent/mailpit/issues/55) from axllent/dependabot/go_modules/golang.org/x/image-0.5.0
|
||||
- Merge pull request [#42](https://github.com/axllent/mailpit/issues/42) from shizunge/dependabot
|
||||
|
||||
|
||||
## [v1.3.8]
|
||||
|
||||
### Bugfix
|
||||
- Restore notification icon
|
||||
|
||||
### UI
|
||||
- Compress SVG icons
|
||||
|
||||
|
||||
## [v1.3.7]
|
||||
|
||||
### Feature
|
||||
- Add Kubernetes API health (livez/readyz) endpoints
|
||||
|
||||
### Libs
|
||||
- Upgrade to esbuild 0.17.5
|
||||
|
||||
|
||||
## [v1.3.6]
|
||||
|
||||
### Bugfix
|
||||
- Correctly index missing 'From' header in database
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update go modules
|
||||
|
||||
|
||||
## [v1.3.5]
|
||||
|
||||
### Bugfix
|
||||
- Include HTML link text in search data
|
||||
|
||||
|
||||
## [v1.3.4]
|
||||
|
||||
### Bugfix
|
||||
- Allow tags to be set from MP_TAG environment
|
||||
|
||||
|
||||
## [v1.3.3]
|
||||
|
||||
### Bugfix
|
||||
- Allow tags to be set from MP_TAG environment
|
||||
|
||||
|
||||
## [v1.3.2]
|
||||
|
||||
### Build
|
||||
- Temporarily disable arm (32) Docker build
|
||||
|
||||
|
||||
## [v1.3.1]
|
||||
|
||||
### Bugfix
|
||||
- Append trailing slash to custom webroot for UI & API
|
||||
|
||||
### Libs
|
||||
- Upgrade esbuild & axios
|
||||
|
||||
### UI
|
||||
- Rename "results" to "result" when singular message returned
|
||||
|
||||
|
||||
## [v1.3.0]
|
||||
|
||||
### Build
|
||||
- Remove duplicate bootstrap CSS
|
||||
|
||||
### Libs
|
||||
- Update go modules
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.2.9]
|
||||
|
||||
### Bugfix
|
||||
- Delay 200ms to set `target="_blank"` for all rendered email links
|
||||
|
||||
|
||||
## [v1.2.8]
|
||||
|
||||
### Bugfix
|
||||
- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
|
||||
|
||||
### Feature
|
||||
- Message tags and auto-tagging
|
||||
|
||||
|
||||
## [v1.2.7]
|
||||
|
||||
### Feature
|
||||
- Allow custom webroot
|
||||
|
||||
|
||||
## [v1.2.6]
|
||||
|
||||
### API
|
||||
- Provide structs of API v1 responses for use in client code
|
||||
|
||||
### Libs
|
||||
- Update go modules
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [1.2.5]
|
||||
|
||||
### UI
|
||||
- Broadcast "delete all" action to reload all connected clients
|
||||
- Load first page if paginated list returns 0 results
|
||||
- Theme changes
|
||||
- Bump build action to use node 18
|
||||
|
||||
|
||||
## [1.2.4]
|
||||
|
||||
### Bugfix
|
||||
- Fix mail download link
|
||||
|
||||
|
||||
## [1.2.3]
|
||||
|
||||
### API
|
||||
- Add limit and start parameters to search
|
||||
|
||||
### UI
|
||||
- Prevent double message index request on websocket connect
|
||||
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
### API
|
||||
- Add API endpoint to return message headers
|
||||
|
||||
### Libs
|
||||
- Update go modules
|
||||
|
||||
### Testing
|
||||
- Add API test for raw & message headers
|
||||
|
||||
|
||||
## [1.2.1]
|
||||
|
||||
### UI
|
||||
- Update frontend modules
|
||||
- Add about app modal with version update notification
|
||||
|
||||
|
||||
## [1.2.0]
|
||||
|
||||
### Feature
|
||||
- Add REST API
|
||||
|
||||
### Testing
|
||||
- Add API tests
|
||||
|
||||
### UI
|
||||
- Changes to use new data API
|
||||
- Hide delete all / mark all read in message view
|
||||
|
||||
|
||||
## [1.1.7]
|
||||
|
||||
### Fix
|
||||
- Normalize running binary name detection (Windows)
|
||||
|
||||
|
||||
## [1.1.6]
|
||||
|
||||
### Fix
|
||||
- Workaround for Safari source matching bug blocking event listener
|
||||
|
||||
### UI
|
||||
- Add documentation link (wiki)
|
||||
|
||||
|
||||
## [1.1.5]
|
||||
|
||||
### Build
|
||||
- Switch to esbuild-sass-plugin
|
||||
|
||||
### UI
|
||||
- Support for inline images using filenames instead of cid
|
||||
|
||||
|
||||
## [1.1.4]
|
||||
|
||||
### Feature
|
||||
- Add --quiet flag to display only errors
|
||||
|
||||
### Security
|
||||
- Add restrictive HTTP Content-Security-Policy
|
||||
|
||||
### UI
|
||||
- Minor UI color change & unread count position adjustment
|
||||
- Add favicon unread message counter
|
||||
- Remove left & right borders (message list)
|
||||
|
||||
|
||||
## [1.1.3]
|
||||
|
||||
### Fix
|
||||
- Update message download link
|
||||
|
||||
|
||||
## [1.1.2]
|
||||
|
||||
### UI
|
||||
- Allow reverse proxy subdirectories
|
||||
|
||||
|
||||
## [1.1.1]
|
||||
|
||||
### UI
|
||||
- Attachment icons and image thumbnails
|
||||
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### UI
|
||||
- HTML source & highlighting
|
||||
- Add previous/next message links
|
||||
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
### Feature
|
||||
- Multiple message selection for group actions using shift/ctrl click
|
||||
- Search parser improvements
|
||||
|
||||
### Feature
|
||||
- Search parser improvements
|
||||
|
||||
### UI
|
||||
- Post data using 'application/json'
|
||||
- Display unknown recipients as as `Undisclosed recipients`
|
||||
- Update frontend modules & esbuild
|
||||
- Update frontend modules & esbuild
|
||||
|
||||
|
||||
## [1.0.0-beta1]
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
|
||||
|
||||
### Feature
|
||||
- Switch backend storage to use SQLite
|
||||
|
||||
### UI
|
||||
- Resize preview iframe on load
|
||||
|
||||
|
||||
## [0.1.5]
|
||||
|
||||
### Feature
|
||||
- Improved message search - any order & phrase quoting
|
||||
|
||||
### UI
|
||||
- Change breakpoints for mobile view of messages
|
||||
- Resize iframes with viewport resize
|
||||
|
||||
|
||||
## [0.1.4]
|
||||
|
||||
### Feature
|
||||
- Email compression in storage
|
||||
|
||||
### Testing
|
||||
- Enable testing on feature branches
|
||||
- Database total/unread statistics tests
|
||||
|
||||
### UI
|
||||
- Mobile compatibility improvements & functionality
|
||||
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
### Feature
|
||||
- Mark all messages as read
|
||||
|
||||
### UI
|
||||
- Better error handling when connection to server is broken
|
||||
- Add reset search button
|
||||
- Minor UI tweaks
|
||||
- Update pagination values when new mail arrives when not on first page
|
||||
|
||||
### Pull Requests
|
||||
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
|
||||
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
### Feature
|
||||
- Optional browser notifications (HTTPS only)
|
||||
@@ -14,19 +786,19 @@ Notable changes to Mailpit will be documented in this file.
|
||||
- Use strconv.Atoi() for safe string to int conversions
|
||||
|
||||
|
||||
## 0.1.1
|
||||
## [0.1.1]
|
||||
|
||||
### Bugfix
|
||||
- Fix env variable for MP_UI_SSL_KEY
|
||||
|
||||
|
||||
## 0.1.0
|
||||
## [0.1.0]
|
||||
|
||||
### Feature
|
||||
- SMTP STARTTLS & SMTP authentication support
|
||||
|
||||
|
||||
## 0.0.9
|
||||
## [0.0.9]
|
||||
|
||||
### Bugfix
|
||||
- Include read status in search results
|
||||
@@ -38,7 +810,7 @@ Notable changes to Mailpit will be documented in this file.
|
||||
- Memory & physical database tests
|
||||
|
||||
|
||||
## 0.0.8
|
||||
## [0.0.8]
|
||||
|
||||
### Bugfix
|
||||
- Fix total/unread count after failed message inserts
|
||||
@@ -47,25 +819,25 @@ Notable changes to Mailpit will be documented in this file.
|
||||
- Add project links to help in CLI
|
||||
|
||||
|
||||
## 0.0.7
|
||||
## [0.0.7]
|
||||
|
||||
### Bugfix
|
||||
- Command flag should be `--auth-file`
|
||||
|
||||
|
||||
## 0.0.6
|
||||
## [0.0.6]
|
||||
|
||||
### Bugfix
|
||||
- Disable CGO when building multi-arch binaries
|
||||
|
||||
|
||||
## 0.0.5
|
||||
## [0.0.5]
|
||||
|
||||
### Feature
|
||||
- Basic authentication support
|
||||
|
||||
|
||||
## 0.0.4
|
||||
## [0.0.4]
|
||||
|
||||
### Bugfix
|
||||
- Update to clover-v2.0.0-alpha.2 to fix sorting
|
||||
@@ -82,15 +854,16 @@ Notable changes to Mailpit will be documented in this file.
|
||||
- cater for messages without From email address
|
||||
|
||||
|
||||
## 0.0.3
|
||||
## [0.0.3]
|
||||
|
||||
### Bugfix
|
||||
- Update to clover-v2.0.0-alpha.2 to fix sorting
|
||||
|
||||
|
||||
## 0.0.2
|
||||
## [0.0.2]
|
||||
|
||||
### Feature
|
||||
- Unread statistics
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git npm && \
|
||||
npm install && npm run package && \
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
|
||||
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -17,4 +16,6 @@ COPY --from=builder /mailpit /mailpit
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 8025/tcp
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
|
||||
102
README.md
102
README.md
@@ -1,58 +1,92 @@
|
||||
# Mailpit
|
||||
# Mailpit - email testing for developers
|
||||
|
||||
Mailpit is an email testing tool for developers.
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/axllent/mailpit)
|
||||
|
||||
It acts as both an SMTP server, and provides a web interface to view all captured emails.
|
||||
Mailpit is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
|
||||
|
||||
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and contains an API for automated integration testing.
|
||||
|
||||

|
||||
Mailpit was originally **inspired** by MailHog which is now [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development for a few years now.
|
||||
|
||||

|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Runs completely on a single binary
|
||||
- Runs entirely from a single binary, no installation required
|
||||
- SMTP server (default `0.0.0.0:1025`)
|
||||
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
|
||||
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
|
||||
- HTML check to test & score mail client compatibility with HTML emails
|
||||
- Link check to test message links (HTML & text) & linked images
|
||||
- Screenshots of HTML messages via web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTML-screenshots))
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- Light & dark web UI theme with auto-detect
|
||||
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
|
||||
- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging))
|
||||
- Real-time web UI updates using web sockets for new mail
|
||||
- Optional browser notifications for new mail (HTTPS only)
|
||||
- Optional browser notifications for new mail (when accessed via either HTTPS or `localhost` only)
|
||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||
- Email storage either in memory or disk ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
|
||||
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
|
||||
- Can handle tens of thousands of emails
|
||||
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
|
||||
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
|
||||
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size, easily handling tens of thousands of emails
|
||||
- SMTP relaying / message release - relay messages via a different SMTP server including an optional allowlist of accepted recipients ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
|
||||
- Optional SMTP with STARTTLS & SMTP authentication, including an "accept anything" mode ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
|
||||
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
|
||||
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
|
||||
- A simple REST API ([see docs](docs/apiv1/README.md))
|
||||
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
|
||||
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
|
||||
|
||||
Mailpit runs as a single binary and can be installed in different ways:
|
||||
|
||||
|
||||
### Install via Brew (Mac)
|
||||
|
||||
Install Mailpit with `brew install mailpit`.
|
||||
|
||||
|
||||
### Install via bash script (Linux & Mac)
|
||||
|
||||
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
|
||||
|
||||
```bash
|
||||
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
|
||||
### Download static binary (Windows, Linux and Mac)
|
||||
|
||||
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can extracted and copied to your `$PATH`, or simply run as `./mailpit`.
|
||||
|
||||
|
||||
### Docker
|
||||
|
||||
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images) for 386, amd64 & arm64 images.
|
||||
|
||||
|
||||
### Compile from source
|
||||
|
||||
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Run `mailpit -h` to see options. More information can be seen in [the docs](https://github.com/axllent/mailpit/wiki/Runtime-options).
|
||||
|
||||
|
||||
|
||||
### Testing Mailpit
|
||||
|
||||
Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Testing-Mailpit) of how to easily test email delivery to Mailpit.
|
||||
|
||||
|
||||
### Configuring sendmail
|
||||
|
||||
There are several different options available:
|
||||
|
||||
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
|
||||
```
|
||||
sendmail_path = /usr/local/bin/mailpit sendmail
|
||||
```
|
||||
|
||||
If Mailpit is found on the same host as sendmail, you can symlink the Mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if Mailpit is running on default 1025 port).
|
||||
|
||||
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
|
||||
|
||||
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
|
||||
|
||||
|
||||
## Why rewrite MailHog?
|
||||
|
||||
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
|
||||
|
||||
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
|
||||
|
||||
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.
|
||||
Mailpit's SMTP server (by default on port 1025), so you will likely need to configure your sending application to deliver mail via that port. A common MTA (Mail Transfer Agent) that delivers system emails to a SMTP server is `sendmail`, used by many applications including PHP. Mailpit can also act as substitute for sendmail. For instructions of how to set this up, please refer to the [sendmail documentation](https://github.com/axllent/mailpit/wiki/Configuring-sendmail).
|
||||
|
||||
213
cmd/root.go
213
cmd/root.go
@@ -1,14 +1,17 @@
|
||||
// Package cmd is the main application
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server"
|
||||
"github.com/axllent/mailpit/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -71,9 +74,77 @@ func init() {
|
||||
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
|
||||
rootCmd.PersistentFlags().Lookup("help").Hidden = true
|
||||
|
||||
// defaults from envars if provided
|
||||
if len(os.Getenv("MP_DATA_DIR")) > 0 {
|
||||
config.DataDir = os.Getenv("MP_DATA_DIR")
|
||||
// load and warn deprecated ENV vars
|
||||
initDeprecatedConfigFromEnv()
|
||||
|
||||
// load ENV vars
|
||||
initConfigFromEnv()
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
|
||||
// deprecated flags 2022/08/06
|
||||
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ssl-cert", config.UITLSCert, "SSL certificate - requires ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ssl-key", config.UITLSKey, "SSL key - requires ssl-cert")
|
||||
rootCmd.Flags().Lookup("auth-file").Hidden = true
|
||||
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
|
||||
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-tls-cert"
|
||||
rootCmd.Flags().Lookup("ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-tls-key"
|
||||
|
||||
// deprecated flags 2022/08/30
|
||||
rootCmd.Flags().StringVar(&config.DataFile, "data", config.DataFile, "Database file to store persistent data")
|
||||
rootCmd.Flags().Lookup("data").Hidden = true
|
||||
rootCmd.Flags().Lookup("data").Deprecated = "use --db-file"
|
||||
|
||||
// deprecated flags 2023/03/12
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-ssl-key", config.SMTPTLSKey, "SSL key for SMTP - requires smtp-ssl-cert")
|
||||
rootCmd.Flags().Lookup("ui-ssl-cert").Hidden = true
|
||||
rootCmd.Flags().Lookup("ui-ssl-cert").Deprecated = "use --ui-tls-cert"
|
||||
rootCmd.Flags().Lookup("ui-ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("ui-ssl-key").Deprecated = "use --ui-tls-key"
|
||||
rootCmd.Flags().Lookup("smtp-ssl-cert").Hidden = true
|
||||
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
|
||||
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
|
||||
}
|
||||
|
||||
// Load settings from environment
|
||||
func initConfigFromEnv() {
|
||||
// inherit from environment if provided
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
config.DataFile = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
@@ -84,55 +155,123 @@ func init() {
|
||||
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
|
||||
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
|
||||
}
|
||||
if len(os.Getenv("MP_TAG")) > 0 {
|
||||
config.SMTPCLITags = os.Getenv("MP_TAG")
|
||||
}
|
||||
|
||||
// UI
|
||||
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_TLS_CERT")) > 0 {
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_TLS_KEY")) > 0 {
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
}
|
||||
|
||||
// SMTP
|
||||
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
if len(os.Getenv("MP_SMTP_TLS_CERT")) > 0 {
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
if len(os.Getenv("MP_SMTP_TLS_KEY")) > 0 {
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
|
||||
config.SMTPAuthAllowInsecure = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
|
||||
config.SMTPStrictRFCHeaders = true
|
||||
}
|
||||
|
||||
// Relay server config
|
||||
if len(os.Getenv("MP_SMTP_RELAY_CONFIG")) > 0 {
|
||||
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
|
||||
config.SMTPRelayAllIncoming = true
|
||||
}
|
||||
|
||||
// Misc options
|
||||
if len(os.Getenv("MP_WEBROOT")) > 0 {
|
||||
config.Webroot = os.Getenv("MP_WEBROOT")
|
||||
}
|
||||
if len(os.Getenv("MP_API_CORS")) > 0 {
|
||||
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
|
||||
config.IgnoreDuplicateIDs = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
|
||||
config.DisableHTMLCheck = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_VERBOSE") {
|
||||
logger.VerboseLogging = true
|
||||
}
|
||||
}
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
fmt.Println("ENV MP_AUTH_FILE has been deprecated, use MP_UI_AUTH_FILE")
|
||||
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_SSL_CERT")
|
||||
fmt.Println("ENV MP_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
config.UITLSCert = os.Getenv("MP_SSL_CERT")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_SSL_KEY")
|
||||
fmt.Println("ENV MP_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
|
||||
config.UITLSKey = os.Getenv("MP_TLS_KEY")
|
||||
}
|
||||
// deprecated 2022/08/28
|
||||
if len(os.Getenv("MP_DATA_DIR")) > 0 {
|
||||
fmt.Println("ENV MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
|
||||
config.DataFile = os.Getenv("MP_DATA_DIR")
|
||||
}
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
|
||||
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
|
||||
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
|
||||
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
|
||||
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to get a boolean from an environment variable
|
||||
func getEnabledFromEnv(k string) bool {
|
||||
if len(os.Getenv(k)) > 0 {
|
||||
v := strings.ToLower(os.Getenv(k))
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UISSLKey, "ui-ssl-key", config.UISSLKey, "SSL key for web UI - requires ui-ssl-cert")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
||||
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
|
||||
|
||||
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
|
||||
|
||||
// deprecated 2022/08/06
|
||||
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UISSLCert, "ssl-cert", config.UISSLCert, "SSL certificate - requires ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UISSLKey, "ssl-key", config.UISSLKey, "SSL key - requires ssl-cert")
|
||||
rootCmd.Flags().Lookup("auth-file").Hidden = true
|
||||
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
|
||||
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-ssl-cert"
|
||||
rootCmd.Flags().Lookup("ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-ssl-key"
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
smtpAddr = "localhost:1025"
|
||||
fromAddr string
|
||||
)
|
||||
|
||||
// sendmailCmd represents the sendmail command
|
||||
var sendmailCmd = &cobra.Command{
|
||||
Use: "sendmail",
|
||||
Short: "A sendmail command replacement",
|
||||
Long: `A sendmail command replacement.
|
||||
|
||||
You can optionally create a symlink called 'sendmail' to the main binary.`,
|
||||
Use: "sendmail [flags] [recipients]",
|
||||
Short: "A sendmail command replacement for Mailpit",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
sendmail.Run()
|
||||
},
|
||||
}
|
||||
@@ -25,9 +20,17 @@ You can optionally create a symlink called 'sendmail' to the main binary.`,
|
||||
func init() {
|
||||
rootCmd.AddCommand(sendmailCmd)
|
||||
|
||||
// these are simply repeated for cli consistency
|
||||
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", "", "SMTP sender")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
// print out manual help screen
|
||||
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
|
||||
|
||||
// these are simply repeated for cli consistency as cobra/viper does not allow
|
||||
// multi-letter single-dash variables (-bs)
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
|
||||
}
|
||||
|
||||
@@ -5,21 +5,11 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/updater"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
)
|
||||
|
||||
// versionCmd represents the version command
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
@@ -36,10 +26,10 @@ var versionCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
||||
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, Version) {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, config.Version) {
|
||||
fmt.Printf(
|
||||
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
||||
latest,
|
||||
@@ -59,7 +49,7 @@ func init() {
|
||||
}
|
||||
|
||||
func updateApp() error {
|
||||
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
|
||||
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
296
config/config.go
296
config/config.go
@@ -1,61 +1,155 @@
|
||||
// Package config handles the application configuration
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/tg123/go-htpasswd"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// SMTPListen to listen on <interface>:<port>
|
||||
SMTPListen = "0.0.0.0:1025"
|
||||
SMTPListen = "[::]:1025"
|
||||
|
||||
// HTTPListen to listen on <interface>:<port>
|
||||
HTTPListen = "0.0.0.0:8025"
|
||||
HTTPListen = "[::]:8025"
|
||||
|
||||
// DataDir for mail (optional)
|
||||
DataDir string
|
||||
// DataFile for mail (optional)
|
||||
DataFile string
|
||||
|
||||
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
|
||||
MaxMessages = 500
|
||||
|
||||
// VerboseLogging for console output
|
||||
VerboseLogging = false
|
||||
// UseMessageDates sets the Created date using the message date, not the delivered date
|
||||
UseMessageDates bool
|
||||
|
||||
// NoLogging for tests
|
||||
NoLogging = false
|
||||
// UITLSCert file
|
||||
UITLSCert string
|
||||
|
||||
// UISSLCert file
|
||||
UISSLCert string
|
||||
|
||||
// UISSLKey file
|
||||
UISSLKey string
|
||||
// UITLSKey file
|
||||
UITLSKey string
|
||||
|
||||
// UIAuthFile for basic authentication
|
||||
UIAuthFile string
|
||||
|
||||
// UIAuth used for euthentication
|
||||
// UIAuth used for authentication
|
||||
UIAuth *htpasswd.File
|
||||
|
||||
// SMTPSSLCert file
|
||||
SMTPSSLCert string
|
||||
// Webroot to define the base path for the UI and API
|
||||
Webroot = "/"
|
||||
|
||||
// SMTPSSLKey file
|
||||
SMTPSSLKey string
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
// SMTPTLSKey file
|
||||
SMTPTLSKey string
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
|
||||
// SMTPAuth used for euthentication
|
||||
SMTPAuth *htpasswd.File
|
||||
// SMTPAuthConfig used for authentication auto-generated from SMTPAuthFile
|
||||
SMTPAuthConfig *htpasswd.File
|
||||
|
||||
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
|
||||
SMTPAuthAllowInsecure bool
|
||||
|
||||
// SMTPAuthAcceptAny accepts any username/password including none
|
||||
SMTPAuthAcceptAny bool
|
||||
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// SMTPCLITags is used to map the CLI args
|
||||
SMTPCLITags string
|
||||
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
|
||||
// SMTPTags are expressions to apply tags to new mail
|
||||
SMTPTags []AutoTag
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
SMTPRelayConfig smtpRelayConfigStruct
|
||||
|
||||
// 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
|
||||
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
|
||||
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
|
||||
ContentSecurityPolicy string
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
)
|
||||
|
||||
// AutoTag struct for auto-tagging
|
||||
type AutoTag struct {
|
||||
Tag string
|
||||
Match string
|
||||
}
|
||||
|
||||
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
|
||||
type smtpRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
|
||||
RecipientAllowlistRegexp *regexp.Regexp
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
|
||||
cssFontRestriction := "*"
|
||||
if BlockRemoteCSSAndFonts {
|
||||
cssFontRestriction = "'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,
|
||||
)
|
||||
|
||||
if DataFile != "" && isDir(DataFile) {
|
||||
DataFile = filepath.Join(DataFile, "mailpit.db")
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(\[([\da-fA-F:])+\])):\d+$`)
|
||||
if !re.MatchString(SMTPListen) {
|
||||
return errors.New("SMTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
@@ -75,31 +169,31 @@ func VerifyConfig() error {
|
||||
UIAuth = a
|
||||
}
|
||||
|
||||
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
|
||||
return errors.New("you must provide both a UI SSL certificate and a key")
|
||||
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
|
||||
return errors.New("You must provide both a UI TLS certificate and a key")
|
||||
}
|
||||
|
||||
if UISSLCert != "" {
|
||||
if !isFile(UISSLCert) {
|
||||
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
|
||||
if UITLSCert != "" {
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("TLS certificate not found: %s", UITLSCert)
|
||||
}
|
||||
|
||||
if !isFile(UISSLKey) {
|
||||
return fmt.Errorf("SSL key not found: %s", UISSLKey)
|
||||
if !isFile(UITLSKey) {
|
||||
return fmt.Errorf("TLS key not found: %s", UITLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPSSLCert != "" && SMTPSSLKey == "" || SMTPSSLCert == "" && SMTPSSLKey != "" {
|
||||
return errors.New("you must provide both an SMTP SSL certificate and a key")
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("You must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPSSLCert != "" {
|
||||
if !isFile(SMTPSSLCert) {
|
||||
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
|
||||
if SMTPTLSCert != "" {
|
||||
if !isFile(SMTPTLSCert) {
|
||||
return fmt.Errorf("SMTP TLS certificate not found: %s", SMTPTLSCert)
|
||||
}
|
||||
|
||||
if !isFile(SMTPSSLKey) {
|
||||
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
|
||||
if !isFile(SMTPTLSKey) {
|
||||
return fmt.Errorf("SMTP TLS key not found: %s", SMTPTLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +202,131 @@ func VerifyConfig() error {
|
||||
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
|
||||
}
|
||||
|
||||
if SMTPSSLCert == "" {
|
||||
return errors.New("SMTP authentication requires SMTP encryption")
|
||||
if SMTPAuthAcceptAny {
|
||||
return errors.New("SMTP authentication can either use --smtp-auth-file or --smtp-auth-accept-any")
|
||||
}
|
||||
|
||||
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SMTPAuth = a
|
||||
SMTPAuthConfig = a
|
||||
}
|
||||
|
||||
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
|
||||
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.]`)
|
||||
if validWebrootRe.MatchString(Webroot) {
|
||||
return fmt.Errorf("Invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - /]", Webroot)
|
||||
}
|
||||
|
||||
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
|
||||
Webroot = s
|
||||
|
||||
SMTPTags = []AutoTag{}
|
||||
|
||||
if SMTPCLITags != "" {
|
||||
args := tools.ArgsParser(SMTPCLITags)
|
||||
|
||||
for _, a := range args {
|
||||
t := strings.Split(a, "=")
|
||||
if len(t) > 1 {
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
}
|
||||
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
|
||||
if len(match) == 0 {
|
||||
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
|
||||
}
|
||||
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
|
||||
} else {
|
||||
return fmt.Errorf("Error parsing tags (%s)", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ReleaseEnabled && SMTPRelayAllIncoming {
|
||||
return errors.New("SMTP relay config must be set to relay all messages")
|
||||
}
|
||||
|
||||
if SMTPRelayAllIncoming {
|
||||
// this deserves a warning
|
||||
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse & validate the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("SMTP relay host not set")
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%s)", c)
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("SMTP relay host username or password not set for LOGIN authentication (%s)", c)
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("SMTP relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("SMTP relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile recipient allowlist regexp: %e", err)
|
||||
}
|
||||
|
||||
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
|
||||
logger.Log().Infof("[smtp] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -131,3 +341,13 @@ func isFile(path string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package data
|
||||
|
||||
import "time"
|
||||
|
||||
// MailboxSummary struct
|
||||
type MailboxSummary struct {
|
||||
Name string
|
||||
Slug string
|
||||
Total int
|
||||
Unread int
|
||||
LastMessage time.Time
|
||||
}
|
||||
|
||||
// WebsocketNotification struct for responses
|
||||
type WebsocketNotification struct {
|
||||
Type string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total int
|
||||
Unread int
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// Message struct for loading messages. It does not include physical attachments.
|
||||
type Message struct {
|
||||
ID string
|
||||
Read bool
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Date time.Time
|
||||
Created time.Time
|
||||
Text string
|
||||
HTML string
|
||||
Size int
|
||||
Inline []Attachment
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
// Attachment struct for inline and attachments
|
||||
type Attachment struct {
|
||||
PartID string
|
||||
FileName string
|
||||
ContentType string
|
||||
ContentID string
|
||||
Size int
|
||||
}
|
||||
|
||||
// Summary struct for frontend messages
|
||||
type Summary struct {
|
||||
ID string
|
||||
Read bool
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Created time.Time
|
||||
Size int
|
||||
Attachments int
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = len(a.Content)
|
||||
|
||||
return o
|
||||
}
|
||||
116
docs/apiv1/Message.md
Normal file
116
docs/apiv1/Message.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Message
|
||||
|
||||
## Message summary
|
||||
|
||||
Returns a JSON summary of the message and attachments.
|
||||
|
||||
**URL** : `api/v1/message/<ID>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
## Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": [],
|
||||
"Bcc": [],
|
||||
"ReplyTo": [],
|
||||
"Subject": "Message subject",
|
||||
"Date": "2016-09-07T16:46:00+13:00",
|
||||
"Tags": ["test"],
|
||||
"Text": "Plain text MIME part of the email",
|
||||
"HTML": "HTML MIME part (if exists)",
|
||||
"Size": 79499,
|
||||
"Inline": [
|
||||
{
|
||||
"PartID": "1.2",
|
||||
"FileName": "filename.gif",
|
||||
"ContentType": "image/gif",
|
||||
"ContentID": "919564503@07092006-1525",
|
||||
"Size": 7760
|
||||
}
|
||||
],
|
||||
"Attachments": [
|
||||
{
|
||||
"PartID": "2",
|
||||
"FileName": "filename.doc",
|
||||
"ContentType": "application/msword",
|
||||
"ContentID": "",
|
||||
"Size": 43520
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
### Notes
|
||||
|
||||
- `From` - Name & Address, or null
|
||||
- `To`, `CC`, `BCC`, `ReplyTo` - Array of Names & Address
|
||||
- `Date` - Parsed email local date & time from headers
|
||||
- `Size` - Total size of raw email
|
||||
- `Inline`, `Attachments` - Array of attachments and inline images.
|
||||
|
||||
|
||||
---
|
||||
## Attachments
|
||||
|
||||
**URL** : `api/v1/message/<ID>/part/<PartID>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the attachment using the MIME type provided by the attachment `ContentType`.
|
||||
|
||||
---
|
||||
## Headers
|
||||
|
||||
**URL** : `api/v1/message/<ID>/headers`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns all message headers as a JSON array.
|
||||
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": [
|
||||
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
|
||||
],
|
||||
"Date": [
|
||||
"Wed, 12 Jul 2006 23:38:30 +1200"
|
||||
],
|
||||
"Delivered-To": [
|
||||
"user@example.com",
|
||||
"user-alias@example.com"
|
||||
],
|
||||
"From": [
|
||||
"\"User Name\" \\u003remote@example.com\\u003e"
|
||||
],
|
||||
"Message-Id": [
|
||||
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
|
||||
],
|
||||
....
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
## Raw (source) email
|
||||
|
||||
**URL** : `api/v1/message/<ID>/raw`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the original email source including headers and attachments.
|
||||
169
docs/apiv1/Messages.md
Normal file
169
docs/apiv1/Messages.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Messages
|
||||
|
||||
List & delete messages.
|
||||
|
||||
|
||||
---
|
||||
## List
|
||||
|
||||
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
|
||||
### Query parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|----------------------------|
|
||||
| limit | integer | false | Limit results (default 50) |
|
||||
| start | integer | false | Pagination offset |
|
||||
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"messages_count": 50,
|
||||
"start": 0,
|
||||
"tags": ["test"],
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": [
|
||||
{
|
||||
"Name": "Accounts",
|
||||
"Address": "accounts@example.com"
|
||||
}
|
||||
],
|
||||
"Bcc": [],
|
||||
"Subject": "Message subject",
|
||||
"Created": "2022-10-03T21:35:32.228605299+13:00",
|
||||
"Tags": ["test"],
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `total` - Total messages in mailbox
|
||||
- `unread` - Total unread messages in mailbox
|
||||
- `messages_count` - Total number of messages in mailbox
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `Read` - The read/unread status of the message
|
||||
- `From` - Name & Address, or null if none
|
||||
- `To`, `CC`, `BCC` - Array of Names & Address
|
||||
- `Created` - Local date & time the message was received
|
||||
- `Size` - Total size of raw email in bytes
|
||||
|
||||
|
||||
---
|
||||
## Delete individual messages
|
||||
|
||||
Delete one or more messages by ID.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `DELETE`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
|
||||
---
|
||||
## Delete all messages
|
||||
|
||||
Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely).
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `DELETE`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": []
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
|
||||
---
|
||||
## Update individual read statuses
|
||||
|
||||
Set the read status of one or more messages.
|
||||
The `read` status can be `true` or `false`.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...],
|
||||
"read": false
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
---
|
||||
## Update all messages read status
|
||||
|
||||
Set the read status of all messages.
|
||||
The `read` status can be `true` or `false`.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": [],
|
||||
"read": false
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
14
docs/apiv1/README.md
Normal file
14
docs/apiv1/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# API v1
|
||||
|
||||
Mailpit provides a simple REST API to access and delete stored messages.
|
||||
|
||||
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
|
||||
|
||||
You can view the Swagger API documentation directly within Mailpit by going to `http://0.0.0.0:8025/api/v1/`.
|
||||
|
||||
The API is split into three main parts:
|
||||
|
||||
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
|
||||
- [Message](Message.md) - Return message data & attachments
|
||||
- [Tags](Tags.md) - Set message tags
|
||||
- [Search](Search.md) - Searching messages
|
||||
70
docs/apiv1/Search.md
Normal file
70
docs/apiv1/Search.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Search
|
||||
|
||||
**URL** : `api/v1/search?query=<string>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
The search returns the most recent matches (default 50).
|
||||
Matching messages are returned in the order of latest received to oldest.
|
||||
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|----------------------------|
|
||||
| query | string | true | Search query |
|
||||
| limit | integer | false | Limit results (default 50) |
|
||||
| start | integer | false | Pagination offset |
|
||||
|
||||
|
||||
## Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"messages_count": 25,
|
||||
"start": 0,
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": [
|
||||
{
|
||||
"Name": "Accounts",
|
||||
"Address": "accounts@example.com"
|
||||
}
|
||||
],
|
||||
"Bcc": [],
|
||||
"Subject": "Test email",
|
||||
"Created": "2022-10-03T21:35:32.228605299+13:00",
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `total` - Total messages in mailbox (all messages, not search)
|
||||
- `unread` - Total unread messages in mailbox (all messages, not search)
|
||||
- `messages_count` - Total number of messages matching search
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `From` - Singular Name & Address, or null if none
|
||||
- `To`, `CC`, `BCC` - Array of Name & Address
|
||||
- `Size` - Total size of raw email in bytes
|
||||
27
docs/apiv1/Tags.md
Normal file
27
docs/apiv1/Tags.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Tags
|
||||
|
||||
Set message tags.
|
||||
|
||||
|
||||
---
|
||||
## Update message tags
|
||||
|
||||
Set the tags for one or more messages.
|
||||
If the tags array is empty then all tags are removed from the messages.
|
||||
|
||||
**URL** : `api/v1/tags`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...],
|
||||
"tags": ["<tag>","<tag>"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -1,22 +0,0 @@
|
||||
const { build } = require('esbuild')
|
||||
const pluginVue = require('esbuild-plugin-vue-next')
|
||||
const sassPlugin = require("esbuild-plugin-sass");
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
|
||||
build({
|
||||
entryPoints: ["server/ui-src/app.js"],
|
||||
bundle: true,
|
||||
watch: doWatch,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
outfile: "server/ui/dist/app.js",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
})
|
||||
37
esbuild.config.mjs
Normal file
37
esbuild.config.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import pluginVue from 'esbuild-plugin-vue-next'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
|
||||
const ctx = await esbuild.context(
|
||||
{
|
||||
entryPoints: [
|
||||
"server/ui-src/app.js",
|
||||
"server/ui-src/docs.js"
|
||||
],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
}
|
||||
)
|
||||
|
||||
if (doWatch) {
|
||||
await ctx.watch()
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
ctx.dispose()
|
||||
}
|
||||
87
go.mod
87
go.mod
@@ -1,55 +1,70 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.18
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jhillyerd/enmime v0.10.0
|
||||
github.com/k3a/html2text v1.0.8
|
||||
github.com/jhillyerd/enmime v1.0.1
|
||||
github.com/k3a/html2text v1.2.1
|
||||
github.com/klauspost/compress v1.17.0
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.2
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/spf13/cobra v1.5.0
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.0
|
||||
github.com/tg123/go-htpasswd v1.2.1
|
||||
github.com/vanng822/go-premailer v1.20.2
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/text v0.13.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/cznic/ql v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang/glog v1.0.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/flatbuffers v2.0.6+incompatible // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/google/orderedcode v0.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.3.1 // indirect
|
||||
github.com/satori/go.uuid v1.2.0 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stretchr/testify v1.7.2 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b // indirect
|
||||
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/image v0.12.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/tools v0.13.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.1 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
377
go.sum
377
go.sum
@@ -1,296 +1,247 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
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/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
|
||||
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
|
||||
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
|
||||
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
|
||||
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
|
||||
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
|
||||
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
|
||||
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
|
||||
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
|
||||
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
|
||||
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
|
||||
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
|
||||
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
|
||||
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
|
||||
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
|
||||
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
|
||||
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
|
||||
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
|
||||
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
|
||||
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
|
||||
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
|
||||
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
|
||||
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
|
||||
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw=
|
||||
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
|
||||
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 h1:EcQR3gusLHN46TAD+G+EbaaqJArt5vHhNpXAa12PQf4=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
|
||||
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
|
||||
github.com/inconshreveable/mousetrap v1.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.0.1 h1:y6RyqIgBOI2hIinOXIzmeB+ITRVls0zTJIm5GwgXnjE=
|
||||
github.com/jhillyerd/enmime v1.0.1/go.mod h1:LMMbm6oTlzWHghPavqHtOrP/NosVv3l42CUrZjn03/Q=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
|
||||
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
|
||||
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
|
||||
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.2 h1:PgOWohvpj4qNCyASJ7Q8Ke8ld/wsoi+dQJ05b1ebwus=
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.2/go.mod h1:7UyIG46NglzTDRKB4LJiS/enXpuo67Lj05eM8mdhL6M=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
|
||||
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
|
||||
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
|
||||
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
|
||||
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
|
||||
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
|
||||
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.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
|
||||
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||
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 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b h1:3ogNYyK4oIQdIKzTu68hQrr4iuVxF3AxKl9Aj/eDrw0=
|
||||
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
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.7.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.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4=
|
||||
golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M=
|
||||
modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
|
||||
modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
|
||||
98
install.sh
Normal file
98
install.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
GH_REPO="axllent/mailpit"
|
||||
TIMEOUT=90
|
||||
|
||||
set -e
|
||||
|
||||
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -ne "\nThere was an error trying to check what is the latest version of Mailpit.\nPlease try again later.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# detect the platform
|
||||
OS="$(uname)"
|
||||
case $OS in
|
||||
Linux)
|
||||
OS='linux'
|
||||
;;
|
||||
FreeBSD)
|
||||
OS='freebsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
NetBSD)
|
||||
OS='netbsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
OpenBSD)
|
||||
OS='openbsd'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
Darwin)
|
||||
OS='darwin'
|
||||
;;
|
||||
SunOS)
|
||||
OS='solaris'
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# detect the arch
|
||||
OS_type="$(uname -m)"
|
||||
case "$OS_type" in
|
||||
x86_64 | amd64)
|
||||
OS_type='amd64'
|
||||
;;
|
||||
i?86 | x86)
|
||||
OS_type='386'
|
||||
;;
|
||||
aarch64 | arm64)
|
||||
OS_type='arm64'
|
||||
;;
|
||||
*)
|
||||
echo 'OS type not supported'
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
|
||||
|
||||
#create tmp directory and move to it with macOS compatibility fallback
|
||||
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
|
||||
cd "$tmp_dir"
|
||||
|
||||
echo "Downloading Mailpit $VERSION"
|
||||
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
|
||||
|
||||
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
|
||||
echo "Error downloading"
|
||||
exit 2
|
||||
}
|
||||
|
||||
mkdir -p /usr/local/bin || exit 2
|
||||
cp mailpit /usr/local/bin/ || exit 2
|
||||
chmod 755 /usr/local/bin/mailpit || exit 2
|
||||
case "$OS" in
|
||||
'linux')
|
||||
chown root:root /usr/local/bin/mailpit || exit 2
|
||||
;;
|
||||
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
|
||||
chown root:wheel /usr/local/bin/mailpit || exit 2
|
||||
;;
|
||||
*)
|
||||
echo 'OS not supported'
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
echo "Installed successfully to /usr/local/bin/mailpit"
|
||||
5
internal/htmlcheck/README.md
Normal file
5
internal/htmlcheck/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# HTML check
|
||||
|
||||
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
|
||||
|
||||
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)
|
||||
4150
internal/htmlcheck/caniemail-data.json
Normal file
4150
internal/htmlcheck/caniemail-data.json
Normal file
File diff suppressed because it is too large
Load Diff
74
internal/htmlcheck/caniemail.go
Normal file
74
internal/htmlcheck/caniemail.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package htmlcheck is used for parsing HTML and returning
|
||||
// HTML compatibility errors and warnings
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
//go:embed caniemail-data.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
var (
|
||||
cie = CanIEmail{}
|
||||
|
||||
noteMatch = regexp.MustCompile(` #(\d)+$`)
|
||||
|
||||
// LimitFamilies will limit results to families if set
|
||||
LimitFamilies = []string{}
|
||||
|
||||
// LimitPlatforms will limit results to platforms if set
|
||||
LimitPlatforms = []string{}
|
||||
|
||||
// LimitClients will limit results to clients if set
|
||||
LimitClients = []string{}
|
||||
)
|
||||
|
||||
// CanIEmail struct for JSON data
|
||||
type CanIEmail struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
LastUpdateDate string `json:"last_update_date"`
|
||||
// NiceNames map[string]string `json:"last_update_date"`
|
||||
NiceNames struct {
|
||||
Family map[string]string `json:"family"`
|
||||
Platform map[string]string `json:"platform"`
|
||||
Support map[string]string `json:"support"`
|
||||
Category map[string]string `json:"category"`
|
||||
} `json:"nicenames"`
|
||||
Data []JSONResult `json:"data"`
|
||||
}
|
||||
|
||||
// JSONResult struct for CanIEmail Data
|
||||
type JSONResult struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
}
|
||||
|
||||
// Load the JSON data
|
||||
func loadJSONData() error {
|
||||
if cie.APIVersion != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := embeddedFS.ReadFile("caniemail-data.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cie = CanIEmail{}
|
||||
|
||||
return json.Unmarshal(b, &cie)
|
||||
}
|
||||
203
internal/htmlcheck/config.go
Normal file
203
internal/htmlcheck/config.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package htmlcheck
|
||||
|
||||
import "regexp"
|
||||
|
||||
// HTML tests
|
||||
var htmlTests = map[string]string{
|
||||
// body check is manually done because it always exists in *goquery.Document
|
||||
"html-body": "body",
|
||||
// HTML tests
|
||||
"html-object": "object, embed, image, pdf",
|
||||
"html-link": "link",
|
||||
"html-dialog": "dialog",
|
||||
"html-srcset": "[srcset]",
|
||||
"html-picture": "picture",
|
||||
"html-svg": "svg",
|
||||
"html-progress": "progress",
|
||||
"html-required": "[required]",
|
||||
"html-meter": "meter",
|
||||
"html-audio": "audio",
|
||||
"html-form": "form",
|
||||
"html-input-submit": "submit",
|
||||
"html-button-reset": "button[type=\"reset\"]",
|
||||
"html-button-submit": "submit, button[type=\"submit\"]",
|
||||
"html-base": "base",
|
||||
"html-input-checkbox": "checkbox",
|
||||
"html-input-hidden": "[type=\"hidden\"]",
|
||||
"html-input-radio": "radio",
|
||||
"html-input-text": "input[type=\"text\"]",
|
||||
"html-video": "video",
|
||||
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
|
||||
"html-select": "select",
|
||||
"html-textarea": "textarea",
|
||||
"html-anchor-links": "a[href^=\"#\"]",
|
||||
"html-style": "style",
|
||||
"html-image-maps": "map, img[usemap]",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
219
internal/htmlcheck/css.go
Normal file
219
internal/htmlcheck/css.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/vanng822/go-premailer/premailer"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// Go cannot calculate any rendered CSS attributes, so we merge all styles
|
||||
// into the HTML and detect elements with styles containing the keywords.
|
||||
func runCSSTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
inlined, err := inlineRemoteCSS(html)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
inlined = html
|
||||
}
|
||||
|
||||
// merge all CSS inline
|
||||
merged, err := mergeInlineCSS(inlined)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
merged = inlined
|
||||
}
|
||||
|
||||
reader := strings.NewReader(merged)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
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
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
allNodeStyles := []string{}
|
||||
for _, n := range doc.Find("*[style]").Nodes {
|
||||
style, err := tools.GetHTMLAttributeVal(n, "style")
|
||||
if err == nil {
|
||||
allNodeStyles = append(allNodeStyles, style)
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpUnitTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := 0
|
||||
// loop through all styles to count total
|
||||
for _, styles := range allNodeStyles {
|
||||
found = found + len(re.FindAllString(styles, -1))
|
||||
}
|
||||
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get all inline CSS block data
|
||||
reader = strings.NewReader(inlined)
|
||||
|
||||
// Load the HTML document
|
||||
doc, _ = goquery.NewDocumentFromReader(reader)
|
||||
|
||||
cssCode := ""
|
||||
for _, n := range doc.Find("style").Nodes {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
cssCode = cssCode + c.Data
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := len(re.FindAllString(cssCode, -1))
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
|
||||
// MergeInlineCSS merges header CSS into element attributes
|
||||
func mergeInlineCSS(html string) (string, error) {
|
||||
options := premailer.NewOptions()
|
||||
// options.RemoveClasses = true
|
||||
// options.CssToAttributes = false
|
||||
options.KeepBangImportant = true
|
||||
pre, err := premailer.NewPremailerFromString(html, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pre.Transform()
|
||||
}
|
||||
|
||||
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
|
||||
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
|
||||
func inlineRemoteCSS(h string) (string, error) {
|
||||
reader := strings.NewReader(h)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range remoteCSS {
|
||||
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 err != nil {
|
||||
logger.Log().Warningf("html check failed to download %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
// create new <style> block and insert downloaded CSS
|
||||
styleBlock := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "style",
|
||||
DataAtom: atom.Style,
|
||||
}
|
||||
styleBlock.AppendChild(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: string(resp),
|
||||
})
|
||||
|
||||
link.Parent.AppendChild(styleBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newDoc, err := doc.Html()
|
||||
if err != nil {
|
||||
logger.Log().Warning(err)
|
||||
return h, err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
102
internal/htmlcheck/html.go
Normal file
102
internal/htmlcheck/html.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// HTML tests
|
||||
func runHTMLTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
reader := strings.NewReader(html)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
// Almost all <script> is bad
|
||||
scripts := len(doc.Find("script:not([type=\"application/ld+json\"]):not([type=\"application/json\"])").Nodes)
|
||||
if scripts > 0 {
|
||||
var result = Warning{}
|
||||
result.Title = "<script> element"
|
||||
result.Slug = "html-script"
|
||||
result.Category = "html"
|
||||
result.Description = "JavaScript is not supported in any email client."
|
||||
result.Tags = []string{}
|
||||
result.Results = []Result{}
|
||||
result.NotesByNumber = map[string]string{}
|
||||
result.Score.Found = scripts
|
||||
result.Score.Supported = 0
|
||||
result.Score.Partial = 0
|
||||
result.Score.Unsupported = 100
|
||||
results = append(results, result)
|
||||
totalTests++
|
||||
}
|
||||
|
||||
for key, test := range htmlTests {
|
||||
totalTests++
|
||||
if test == "body" {
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
if re.MatchString(html) {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
result.Score.Found = 1
|
||||
results = append(results, result)
|
||||
}
|
||||
} else if len(doc.Find(test).Nodes) > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
totalTests++
|
||||
|
||||
result.Score.Found = len(doc.Find(test).Nodes)
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// find all images
|
||||
images := doc.Find("img[src]").Nodes
|
||||
imageResults := make(map[string]int)
|
||||
totalTests = totalTests + len(imageRegexpTests)
|
||||
|
||||
for _, image := range images {
|
||||
src, err := tools.GetHTMLAttributeVal(image, "src")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for key, test := range imageRegexpTests {
|
||||
if test.MatchString(src) {
|
||||
matches, exists := imageResults[key]
|
||||
if exists {
|
||||
imageResults[key] = matches + 1
|
||||
} else {
|
||||
imageResults[key] = 1
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, found := range imageResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
212
internal/htmlcheck/main.go
Normal file
212
internal/htmlcheck/main.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(html string) (Response, error) {
|
||||
s := Response{}
|
||||
s.Warnings = []Warning{}
|
||||
if platforms, err := Platforms(); err == nil {
|
||||
s.Platforms = platforms
|
||||
}
|
||||
|
||||
s.Total = Total{}
|
||||
|
||||
// crude way to determine whether the HTML contains a <body> structure
|
||||
// or whether it's just plain HTML content
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
nodeMatch := "body *, script"
|
||||
if re.MatchString(html) {
|
||||
nodeMatch = "*:not(html):not(head):not(meta), script"
|
||||
}
|
||||
reader := strings.NewReader(html)
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
// calculate the number of nodes in HTML
|
||||
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// HTML tests
|
||||
htmlResults, totalTests, err := runHTMLTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add html test totals
|
||||
s.Warnings = append(s.Warnings, htmlResults...)
|
||||
|
||||
// CSS tests
|
||||
cssResults, totalTests, err := runCSSTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add css test totals
|
||||
s.Warnings = append(s.Warnings, cssResults...)
|
||||
|
||||
// calculate total score
|
||||
var partial, unsupported float32
|
||||
partial = 0
|
||||
unsupported = 0
|
||||
|
||||
for _, w := range s.Warnings {
|
||||
if w.Score.Found == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// supported is calculated by subtracting partial and unsupported from 100%
|
||||
if w.Score.Partial > 0 {
|
||||
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > partial {
|
||||
partial = weighted
|
||||
}
|
||||
}
|
||||
if w.Score.Unsupported > 0 {
|
||||
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > unsupported {
|
||||
unsupported = weighted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Total.Supported = 100 - partial - unsupported
|
||||
s.Total.Partial = partial
|
||||
s.Total.Unsupported = unsupported
|
||||
|
||||
// sort slice to get lowest scores first
|
||||
sort.Slice(s.Warnings, func(i, j int) bool {
|
||||
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
|
||||
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Test returns a test
|
||||
func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
warning := Warning{}
|
||||
exists := false
|
||||
found := JSONResult{}
|
||||
for _, r := range cie.Data {
|
||||
if r.Slug == k {
|
||||
found = r
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return warning, fmt.Errorf("%s does not exist", k)
|
||||
}
|
||||
|
||||
warning.Slug = found.Slug
|
||||
warning.Title = found.Title
|
||||
warning.Description = mdToHTML(found.Description)
|
||||
warning.Category = found.Category
|
||||
warning.URL = found.URL
|
||||
warning.Tags = found.Tags
|
||||
// warning.Keywords = found.Keywords
|
||||
// warning.Notes = found.Notes
|
||||
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
|
||||
for nr, note := range found.NotesByNumber {
|
||||
warning.NotesByNumber[nr] = mdToHTML(note)
|
||||
}
|
||||
warning.Results = []Result{}
|
||||
|
||||
var y, n, p float32
|
||||
|
||||
for family, stats := range found.Stats {
|
||||
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
s := Result{}
|
||||
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
|
||||
s.Family = family
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
warning.Results = append(warning.Results, s)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
total := y + n + p
|
||||
warning.Score.Supported = y / total * 100
|
||||
warning.Score.Unsupported = n / total * 100
|
||||
warning.Score.Partial = p / total * 100
|
||||
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func inArray(n string, h []string) bool {
|
||||
n = strings.ToLower(n)
|
||||
|
||||
for _, v := range h {
|
||||
if strings.ToLower(v) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert markdown to HTML, stripping <p> & </p>
|
||||
func mdToHTML(str string) string {
|
||||
md := []byte(str)
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
// extensions := parser.NoExtensions
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse(md)
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
|
||||
}
|
||||
38
internal/htmlcheck/platforms.go
Normal file
38
internal/htmlcheck/platforms.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package htmlcheck
|
||||
|
||||
import "sort"
|
||||
|
||||
// Platforms returns all platforms with their respective email clients
|
||||
func Platforms() (map[string][]string, error) {
|
||||
// [platform]clients
|
||||
data := make(map[string][]string)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
for _, t := range cie.Data {
|
||||
for family, stats := range t.Stats {
|
||||
niceFamily := cie.NiceNames.Family[family]
|
||||
for platform := range stats.(map[string]interface{}) {
|
||||
c, found := data[platform]
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
}
|
||||
if !inArray(niceFamily, c) {
|
||||
c = append(c, niceFamily)
|
||||
data[platform] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group, clients := range data {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i] < clients[j]
|
||||
})
|
||||
data[group] = clients
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
87
internal/htmlcheck/structs.go
Normal file
87
internal/htmlcheck/structs.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package htmlcheck
|
||||
|
||||
// Response represents the HTML check response struct
|
||||
//
|
||||
// swagger:model HTMLCheckResponse
|
||||
type Response struct {
|
||||
// List of warnings from tests
|
||||
Warnings []Warning `json:"Warnings"`
|
||||
// All platforms tested, mainly for the web UI
|
||||
Platforms map[string][]string `json:"Platforms"`
|
||||
// Total overall score
|
||||
Total Total `json:"Total"`
|
||||
}
|
||||
|
||||
// Warning represents a failed test
|
||||
//
|
||||
// swagger:model HTMLCheckWarning
|
||||
type Warning struct {
|
||||
// Slug identifier
|
||||
Slug string `json:"Slug"`
|
||||
// Friendly title
|
||||
Title string `json:"Title"`
|
||||
// Description
|
||||
Description string `json:"Description"`
|
||||
// URL to caniemail.com
|
||||
URL string `json:"URL"`
|
||||
// Category [css, html]
|
||||
Category string `json:"Category"`
|
||||
// Tags
|
||||
Tags []string `json:"Tags"`
|
||||
// Keywords
|
||||
Keywords string `json:"Keywords"`
|
||||
// Test results
|
||||
Results []Result `json:"Results"`
|
||||
// Notes based on results
|
||||
NotesByNumber map[string]string `json:"NotesByNumber"`
|
||||
// Test score calculated from results
|
||||
Score Score `json:"Score"`
|
||||
}
|
||||
|
||||
// Result struct
|
||||
//
|
||||
// swagger:model HTMLCheckResult
|
||||
type Result struct {
|
||||
// Friendly name of result, combining family, platform & version
|
||||
Name string `json:"Name"`
|
||||
// Platform eg: ios, android, windows
|
||||
Platform string `json:"Platform"`
|
||||
// Family eg: Outlook, Mozilla Thunderbird
|
||||
Family string `json:"Family"`
|
||||
// Family version eg: 4.7.1, 2019-10, 10.3
|
||||
Version string `json:"Version"`
|
||||
// Support [yes, no, partial]
|
||||
Support string `json:"Support"`
|
||||
// Note number for partially supported if applicable
|
||||
NoteNumber string `json:"NoteNumber"` // where applicable
|
||||
}
|
||||
|
||||
// Score struct
|
||||
//
|
||||
// swagger:model HTMLCheckScore
|
||||
type Score struct {
|
||||
// Number of matches in the document
|
||||
Found int `json:"Found"`
|
||||
// Total percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Total percentage partially supported
|
||||
Partial float32 `json:"Partial"`
|
||||
// Total percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"`
|
||||
}
|
||||
|
||||
// Total weighted result for all scores
|
||||
//
|
||||
// swagger:model HTMLCheckTotal
|
||||
type Total struct {
|
||||
// Total number of tests done
|
||||
Tests int `json:"Tests"`
|
||||
// Total number of HTML nodes detected in message
|
||||
Nodes int `json:"Nodes"`
|
||||
// Overall percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Overall percentage partially supported
|
||||
Partial float32 `json:"Partial"` // total percentage
|
||||
// Overall percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"` // total percentage
|
||||
}
|
||||
92
internal/linkcheck/main.go
Normal file
92
internal/linkcheck/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package linkcheck handles message links checking
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
s := Response{}
|
||||
|
||||
allLinks := extractHTMLLinks(msg)
|
||||
allLinks = strUnique(append(allLinks, extractTextLinks(msg)...))
|
||||
s.Links = getHTTPStatuses(allLinks, followRedirects)
|
||||
|
||||
for _, l := range s.Links {
|
||||
if l.StatusCode >= 400 || l.StatusCode == 0 {
|
||||
s.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func extractTextLinks(msg *storage.Message) []string {
|
||||
links := []string{}
|
||||
|
||||
for _, match := range linkRe.FindAllString(msg.Text, -1) {
|
||||
links = append(links, match)
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
func extractHTMLLinks(msg *storage.Message) []string {
|
||||
links := []string{}
|
||||
|
||||
reader := strings.NewReader(msg.HTML)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return links
|
||||
}
|
||||
|
||||
aLinks := doc.Find("a[href]").Nodes
|
||||
for _, link := range aLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "href")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range cssLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "href")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
imgLinks := doc.Find("img[src]").Nodes
|
||||
for _, link := range imgLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "src")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
// strUnique return a slice of unique strings from a slice
|
||||
func strUnique(strSlice []string) []string {
|
||||
keys := make(map[string]bool)
|
||||
list := []string{}
|
||||
for _, entry := range strSlice {
|
||||
if _, value := keys[entry]; !value {
|
||||
keys[entry] = true
|
||||
list = append(list, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
106
internal/linkcheck/status.go
Normal file
106
internal/linkcheck/status.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
// allow 5 threads
|
||||
threads := make(chan int, 5)
|
||||
|
||||
results := make(map[string]Link, len(links))
|
||||
resultsMutex := sync.RWMutex{}
|
||||
|
||||
output := []Link{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, l := range links {
|
||||
wg.Add(1)
|
||||
go func(link string, w *sync.WaitGroup) {
|
||||
threads <- 1 // will block if MAX threads
|
||||
defer w.Done()
|
||||
|
||||
code, err := doHead(link, followRedirects)
|
||||
l := Link{}
|
||||
l.URL = link
|
||||
if err != nil {
|
||||
l.StatusCode = 0
|
||||
l.Status = httpErrorSummary(err)
|
||||
} else {
|
||||
l.StatusCode = code
|
||||
l.Status = http.StatusText(code)
|
||||
}
|
||||
resultsMutex.Lock()
|
||||
results[link] = l
|
||||
resultsMutex.Unlock()
|
||||
|
||||
<-threads // remove from threads
|
||||
}(l, &wg)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for _, l := range results {
|
||||
output = append(output, l)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// Do a HEAD request to return HTTP status code
|
||||
func doHead(link string, followRedirects bool) (int, error) {
|
||||
|
||||
timeout := time.Duration(10 * time.Second)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if followRedirects {
|
||||
return nil
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("HEAD", link, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
return res.StatusCode, err
|
||||
}
|
||||
|
||||
return 0, err
|
||||
|
||||
}
|
||||
|
||||
return res.StatusCode, nil
|
||||
}
|
||||
|
||||
// HTTP errors include a lot more info that just the actual error, so this
|
||||
// tries to take the final part of it, eg: `no such host`
|
||||
func httpErrorSummary(err error) string {
|
||||
var re = regexp.MustCompile(`.*: (.*)$`)
|
||||
|
||||
e := err.Error()
|
||||
if !re.MatchString(e) {
|
||||
return e
|
||||
}
|
||||
|
||||
parts := re.FindAllStringSubmatch(e, -1)
|
||||
|
||||
return parts[0][len(parts[0])-1]
|
||||
}
|
||||
21
internal/linkcheck/structs.go
Normal file
21
internal/linkcheck/structs.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package linkcheck
|
||||
|
||||
// Response represents the Link check response
|
||||
//
|
||||
// swagger:model LinkCheckResponse
|
||||
type Response struct {
|
||||
// Total number of errors
|
||||
Errors int `json:"Errors"`
|
||||
// Tested links
|
||||
Links []Link `json:"Links"`
|
||||
}
|
||||
|
||||
// Link struct
|
||||
type Link struct {
|
||||
// Link URL
|
||||
URL string `json:"URL"`
|
||||
// HTTP status code
|
||||
StatusCode int `json:"StatusCode"`
|
||||
// HTTP status definition
|
||||
Status string `json:"Status"`
|
||||
}
|
||||
76
internal/logger/logger.go
Normal file
76
internal/logger/logger.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package logger handles the logging
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
log *logrus.Logger
|
||||
// VerboseLogging for verbose logging
|
||||
VerboseLogging bool
|
||||
// QuietLogging shows only errors
|
||||
QuietLogging bool
|
||||
// NoLogging shows only fatal errors
|
||||
NoLogging bool
|
||||
)
|
||||
|
||||
// Log returns the logger instance
|
||||
func Log() *logrus.Logger {
|
||||
if log == nil {
|
||||
log = logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
if VerboseLogging {
|
||||
// verbose logging (debug)
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
} else if QuietLogging {
|
||||
// show errors only
|
||||
log.SetLevel(logrus.ErrorLevel)
|
||||
} else if NoLogging {
|
||||
// disable all logging (tests)
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
}
|
||||
|
||||
log.Out = os.Stdout
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006/01/02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// PrettyPrint for debugging
|
||||
func PrettyPrint(i interface{}) {
|
||||
s, _ := json.MarshalIndent(i, "", "\t")
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
|
||||
// CleanIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
|
||||
func CleanIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
return "0.0.0.0:" + s[5:]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// CleanHTTPIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
func CleanHTTPIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
return "localhost:" + s[5:]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
842
internal/storage/database.go
Normal file
842
internal/storage/database.go
Normal file
@@ -0,0 +1,842 @@
|
||||
// Package storage handles all database actions
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/GuiaBolso/darwin"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/leporo/sqlf"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
|
||||
// sqlite (native) - https://gitlab.com/cznic/sqlite
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
dbLastAction time.Time
|
||||
dbIsIdle bool
|
||||
dbDataDeleted bool
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
dbDecoder, _ = zstd.NewReader(nil)
|
||||
|
||||
dbMigrations = []darwin.Migration{
|
||||
{
|
||||
Version: 1.0,
|
||||
Description: "Creating tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailbox (
|
||||
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
Data BLOB,
|
||||
Search TEXT,
|
||||
Read INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mailbox_data (
|
||||
ID TEXT KEY NOT NULL,
|
||||
Email BLOB
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
|
||||
},
|
||||
{
|
||||
Version: 1.1,
|
||||
Description: "Create tags column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.2,
|
||||
Description: "Creating new mailbox format",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
|
||||
Created INTEGER NOT NULL,
|
||||
ID TEXT NOT NULL,
|
||||
MessageID TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
Metadata TEXT,
|
||||
Size INTEGER NOT NULL,
|
||||
Inline INTEGER NOT NULL,
|
||||
Attachments INTEGER NOT NULL,
|
||||
Read INTEGER,
|
||||
Tags TEXT,
|
||||
SearchText TEXT
|
||||
);
|
||||
INSERT INTO mailboxtmp
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
|
||||
SELECT
|
||||
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
|
||||
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
|
||||
Search, Read, Tags
|
||||
FROM mailbox;
|
||||
|
||||
DROP TABLE IF EXISTS mailbox;
|
||||
ALTER TABLE mailboxtmp RENAME TO mailbox;
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
|
||||
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
|
||||
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
}
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
p := config.DataFile
|
||||
|
||||
if p == "" {
|
||||
// when no path is provided then we create a temporary file
|
||||
// which will get deleted on Close(), SIGINT or SIGTERM
|
||||
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
|
||||
dbIsTemp = true
|
||||
logger.Log().Debugf("[db] using temporary database: %s", p)
|
||||
} else {
|
||||
p = filepath.Clean(p)
|
||||
}
|
||||
|
||||
config.DataFile = p
|
||||
|
||||
logger.Log().Debugf("[db] opening database %s", p)
|
||||
|
||||
var err error
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?cache=shared", p)
|
||||
|
||||
db, err = sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// prevent "database locked" errors
|
||||
// @see https://github.com/mattn/go-sqlite3#faq
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// create tables if necessary & apply migrations
|
||||
if err := dbApplyMigrations(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbFile = p
|
||||
dbLastAction = time.Now()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
// catch all signals since not explicitly listing
|
||||
// Program that will listen to the SIGINT and SIGTERM
|
||||
// SIGINT will listen to CTRL-C.
|
||||
// SIGTERM will be caught if kill command executed
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
// method invoked upon seeing signal
|
||||
go func() {
|
||||
s := <-sigs
|
||||
fmt.Printf("[db] got %s signal, shutting down\n", s)
|
||||
Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// auto-prune & delete
|
||||
go dbCron()
|
||||
|
||||
go dataMigrations()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create tables and apply migrations if required
|
||||
func dbApplyMigrations() error {
|
||||
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
|
||||
|
||||
d := darwin.New(driver, dbMigrations, nil)
|
||||
|
||||
return d.Migrate()
|
||||
}
|
||||
|
||||
// Close will close the database, and delete if a temporary table
|
||||
func Close() {
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Log().Warning("[db] error closing database, ignoring")
|
||||
}
|
||||
}
|
||||
|
||||
if dbIsTemp && isFile(dbFile) {
|
||||
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
|
||||
if err := os.Remove(dbFile); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store will save an email to the database tables.
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body []byte) (string, error) {
|
||||
// Parse message body with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
logger.Log().Warningf("[db] %s", err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
}
|
||||
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
if config.UseMessageDates {
|
||||
if mDate, err := env.Date(); err == nil {
|
||||
created = mDate
|
||||
}
|
||||
}
|
||||
|
||||
// generate the search text
|
||||
searchText := createSearchText(env)
|
||||
|
||||
// generate unique ID
|
||||
id := uuid.NewV4().String()
|
||||
|
||||
summaryJSON, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// extract tags from body matches based on --tag
|
||||
tagStr := findTagsInRawMessage(&body)
|
||||
|
||||
// extract tags from X-Tags header
|
||||
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
if headerTags != "" {
|
||||
tagStr += "," + headerTags
|
||||
}
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
tagJSON, err := json.Marshal(tagData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are stored successfully
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := len(body)
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
|
||||
// insert mail summary data
|
||||
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read) values(?,?,?,?,?,?,?,?,?,?,0)",
|
||||
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert compressed raw message
|
||||
compressed := dbEncoder.EncodeAll(body, make([]byte, 0, len(body)))
|
||||
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
c := &MessageSummary{}
|
||||
if err := json.Unmarshal(summaryJSON, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
c.Created = created
|
||||
c.ID = id
|
||||
c.MessageID = messageID
|
||||
c.Attachments = attachments
|
||||
c.Subject = subject
|
||||
c.Size = size
|
||||
c.Tags = tagData
|
||||
|
||||
websockets.Broadcast("new", c)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
|
||||
OrderBy("Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
|
||||
results = append(results, em)
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a Message generated from the mailbox_data collection.
|
||||
// If the message lacks a date header, then the received datetime is used.
|
||||
func GetMessage(id string) (*Message, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
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")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
|
||||
if returnPath == "" && from != nil {
|
||||
returnPath = from.Address
|
||||
}
|
||||
|
||||
date, err := env.Date()
|
||||
if err != nil {
|
||||
// return received datetime when message does not contain a date header
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created`).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(created)
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
obj := Message{
|
||||
ID: id,
|
||||
MessageID: messageID,
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: len(raw),
|
||||
Text: env.Text,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
|
||||
for _, i := range env.Inlines {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range env.OtherParts {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.FileName != "" || a.ContentID != "" {
|
||||
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
|
||||
}
|
||||
}
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
return &obj, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(id string) ([]byte, error) {
|
||||
var i string
|
||||
var msg string
|
||||
q := sqlf.From("mailbox_data").
|
||||
Select(`ID`).To(&i).
|
||||
Select(`Email`).To(&msg).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i == "" {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, a := range env.Inlines {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.OtherParts {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil, errors.New("attachment not found")
|
||||
}
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(id string) error {
|
||||
if !IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkAllRead will mark all messages as read
|
||||
func MarkAllRead() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountUnread()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("Read = ?", 0).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllUnread will mark all messages as unread
|
||||
func MarkAllUnread() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountRead()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 0).
|
||||
Where("Read = ?", 1).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("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()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteOneMessage will delete a single message from a mailbox
|
||||
func DeleteOneMessage(id string) error {
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted message %s", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total int
|
||||
)
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(nil, db)
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// summaries and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("VACUUM")
|
||||
if err == nil {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = false
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllTags returns all used tags
|
||||
func GetAllTags() []string {
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`DISTINCT Tags`).
|
||||
Where("Tags != ?", "[]")
|
||||
|
||||
var tags = []string{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var tagData string
|
||||
t := []string{}
|
||||
|
||||
if err := row.Scan(&tagData); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagData), &t); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range t {
|
||||
if !inArray(tag, tags) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// StatsGet returns the total/unread statistics for a mailbox
|
||||
func StatsGet() MailboxStats {
|
||||
var (
|
||||
total = CountTotal()
|
||||
unread = CountUnread()
|
||||
tags = GetAllTags()
|
||||
)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return MailboxStats{
|
||||
Total: total,
|
||||
Unread: unread,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() int {
|
||||
var total int
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(nil, db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() int {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() int {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// IsUnread returns the number of emails in the database that are unread.
|
||||
// If an ID is supplied, then it is just limited to that message.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&unread).
|
||||
Where("Read = ?", 0).
|
||||
Where("ID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
|
||||
return unread == 1
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("MessageID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
|
||||
return total != 0
|
||||
}
|
||||
143
internal/storage/database_test.go
Normal file
143
internal/storage/database_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing text email storage")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
}
|
||||
|
||||
func TestMimeEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing mime email storage")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing mime email retrieval")
|
||||
|
||||
id, err := Store(testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
}
|
||||
|
||||
func BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImportMime(b *testing.B) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
200
internal/storage/migrationTasks.go
Normal file
200
internal/storage/migrationTasks.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/leporo/sqlf"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
func dataMigrations() {
|
||||
updateOrderByCreatedTask()
|
||||
assignMessageIDsTask()
|
||||
}
|
||||
|
||||
// Update Created column using Created metadata datetime <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func updateOrderByCreatedTask() {
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Select(`json_extract(Metadata, '$.Created') as Created`).
|
||||
Where("Created < ?", 1155000600)
|
||||
|
||||
toUpdate := make(map[string]int64)
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var ts sql.NullString
|
||||
if err := row.Scan(&id, &ts); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ts.Valid {
|
||||
logger.Log().Errorf("[migration] cannot get Created timestamp from %s", id)
|
||||
return
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339Nano, ts.String)
|
||||
toUpdate[id] = t.UnixMilli()
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
total := len(toUpdate)
|
||||
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] updating timestamp for %s messages", p.Sprintf("%d", len(toUpdate)))
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
var blockTime = time.Now()
|
||||
|
||||
count := 0
|
||||
for id, ts := range toUpdate {
|
||||
count++
|
||||
_, err := tx.Exec(`UPDATE mailbox SET Created = ? WHERE ID = ?`, ts, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] updated timestamp for 1,000 messages [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
|
||||
// Find any messages without a stored Message-ID and update it <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func assignMessageIDsTask() {
|
||||
if !config.IgnoreDuplicateIDs {
|
||||
return
|
||||
}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Where("MessageID = ''")
|
||||
|
||||
missingIDS := make(map[string]string)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
if err := row.Scan(&id); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
missingIDS[id] = ""
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if len(missingIDS) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
var blockTime = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
total := len(missingIDS)
|
||||
|
||||
logger.Log().Infof("[migration] extracting Message-IDs for %s messages", p.Sprintf("%d", total))
|
||||
|
||||
for id := range missingIDS {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
missingIDS[id] = messageID
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] extracted 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
count = 0
|
||||
|
||||
for id, mid := range missingIDS {
|
||||
_, err = tx.Exec(`UPDATE mailbox SET MessageID = ? WHERE ID = ?`, mid, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] stored 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
35
internal/storage/notifications.go
Normal file
35
internal/storage/notifications.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
var bcStatsDelay = false
|
||||
|
||||
// BroadcastMailboxStats broadcasts the total number of messages
|
||||
// displayed to the web UI, as well as the total unread messages.
|
||||
// The lookup is very fast (< 10ms / 100k messages under load).
|
||||
// Rate limited to 4x per second.
|
||||
func BroadcastMailboxStats() {
|
||||
if bcStatsDelay {
|
||||
return
|
||||
}
|
||||
|
||||
bcStatsDelay = true
|
||||
|
||||
go func() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total int
|
||||
Unread int
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
Unread: CountUnread(),
|
||||
}
|
||||
|
||||
websockets.Broadcast("stats", b)
|
||||
}()
|
||||
}
|
||||
318
internal/storage/search.go
Normal file
318
internal/storage/search.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// Search will search a mailbox 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>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
results := []MessageSummary{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
nrResults := 0
|
||||
if limit < 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search)
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
|
||||
allResults = append(allResults, em)
|
||||
}); err != nil {
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
nrResults = len(allResults)
|
||||
|
||||
if nrResults > start {
|
||||
end := nrResults
|
||||
if nrResults >= start+limit {
|
||||
end = start + limit
|
||||
}
|
||||
|
||||
results = allResults[start:end]
|
||||
}
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
|
||||
|
||||
return results, nrResults, 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>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func DeleteSearch(search string) error {
|
||||
q := searchQueryBuilder(search)
|
||||
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
total := len(ids)
|
||||
|
||||
// split ids into chunks of 1000 ids
|
||||
var chunks [][]string
|
||||
if total > 1000 {
|
||||
chunkSize := 1000
|
||||
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
|
||||
for chunkSize < len(ids) {
|
||||
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
// add remaining ids <= 1000
|
||||
chunks = append(chunks, ids)
|
||||
}
|
||||
} else {
|
||||
chunks = append(chunks, ids)
|
||||
}
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, ids := range chunks {
|
||||
delIDs := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
delIDs[i] = id
|
||||
}
|
||||
|
||||
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
_, err = tx.Exec(sqlDelete1, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
_, err = tx.Exec(sqlDelete2, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
BroadcastMailboxStats()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
searchString = strings.ToLower(searchString)
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
args := tools.ArgsParser(searchString)
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
|
||||
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
|
||||
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
|
||||
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
|
||||
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON
|
||||
`).OrderBy("Created DESC")
|
||||
|
||||
for _, w := range args {
|
||||
if cleanString(w) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
exclude := false
|
||||
// search terms starting with a `-` or `!` imply an exclude
|
||||
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
|
||||
exclude = true
|
||||
w = w[1:]
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||
if !re.MatchString(w) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(w, "to:") {
|
||||
w = cleanString(w[3:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "from:") {
|
||||
w = cleanString(w[5:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "cc:") {
|
||||
w = cleanString(w[3:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "bcc:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "subject:") {
|
||||
w = w[8:]
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "message-id:") {
|
||||
w = cleanString(w[11:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "tag:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%")
|
||||
} else {
|
||||
q.Where("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%")
|
||||
}
|
||||
}
|
||||
} else if w == "is:read" {
|
||||
if exclude {
|
||||
q.Where("Read = 0")
|
||||
} else {
|
||||
q.Where("Read = 1")
|
||||
}
|
||||
} else if w == "is:unread" {
|
||||
if exclude {
|
||||
q.Where("Read = 1")
|
||||
} else {
|
||||
q.Where("Read = 0")
|
||||
}
|
||||
} else if w == "is:tagged" {
|
||||
if exclude {
|
||||
q.Where("Tags = ?", "[]")
|
||||
} else {
|
||||
q.Where("Tags != ?", "[]")
|
||||
}
|
||||
} else if w == "has:attachment" || w == "has:attachments" {
|
||||
if exclude {
|
||||
q.Where("Attachments = 0")
|
||||
} else {
|
||||
q.Where("Attachments > 0")
|
||||
}
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
} else {
|
||||
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
152
internal/storage/search_test.go
Normal file
152
internal/storage/search_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search")
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := Store(buf.Bytes()); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
searchIdx := rand.Intn(4) + 1
|
||||
var search string
|
||||
switch searchIdx {
|
||||
case 1:
|
||||
search = fmt.Sprintf("from-%d@example.com", i)
|
||||
case 2:
|
||||
search = fmt.Sprintf("to-%d@example.com", i)
|
||||
case 3:
|
||||
search = fmt.Sprintf("\"Subject line %d end\"", i)
|
||||
default:
|
||||
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
|
||||
}
|
||||
|
||||
summaries, _, err := Search(search, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "1 search result expected")
|
||||
|
||||
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
|
||||
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), testRuns, "search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete1100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 1100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
114
internal/storage/structs.go
Normal file
114
internal/storage/structs.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// Message data excluding physical attachments
|
||||
//
|
||||
// swagger:model Message
|
||||
type Message struct {
|
||||
// Database ID
|
||||
ID string
|
||||
// Message ID
|
||||
MessageID string
|
||||
// From address
|
||||
From *mail.Address
|
||||
// To addresses
|
||||
To []*mail.Address
|
||||
// Cc addresses
|
||||
Cc []*mail.Address
|
||||
// Bcc addresses
|
||||
Bcc []*mail.Address
|
||||
// ReplyTo addresses
|
||||
ReplyTo []*mail.Address
|
||||
// Return-Path
|
||||
ReturnPath string
|
||||
// Message subject
|
||||
Subject string
|
||||
// Message date if set, else date received
|
||||
Date time.Time
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message body text
|
||||
Text string
|
||||
// Message body HTML
|
||||
HTML string
|
||||
// Message size in bytes
|
||||
Size int
|
||||
// Inline message attachments
|
||||
Inline []Attachment
|
||||
// Message attachments
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
// Attachment struct for inline and attachments
|
||||
//
|
||||
// swagger:model Attachment
|
||||
type Attachment struct {
|
||||
// Attachment part ID
|
||||
PartID string
|
||||
// File name
|
||||
FileName string
|
||||
// Content type
|
||||
ContentType string
|
||||
// Content ID
|
||||
ContentID string
|
||||
// Size in bytes
|
||||
Size int
|
||||
}
|
||||
|
||||
// MessageSummary struct for frontend messages
|
||||
//
|
||||
// swagger:model MessageSummary
|
||||
type MessageSummary struct {
|
||||
// Database ID
|
||||
ID string
|
||||
// Message ID
|
||||
MessageID string
|
||||
// Read status
|
||||
Read bool
|
||||
// From address
|
||||
From *mail.Address
|
||||
// To address
|
||||
To []*mail.Address
|
||||
// Cc addresses
|
||||
Cc []*mail.Address
|
||||
// Bcc addresses
|
||||
Bcc []*mail.Address
|
||||
// Email subject
|
||||
Subject string
|
||||
// Created time
|
||||
Created time.Time
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message size in bytes (total)
|
||||
Size int
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
}
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total int
|
||||
Unread int
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = len(a.Content)
|
||||
|
||||
return o
|
||||
}
|
||||
113
internal/storage/tags.go
Normal file
113
internal/storage/tags.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetTags will set the tags for a given database ID, used via API
|
||||
func SetTags(id string, tags []string) error {
|
||||
applyTags := []string{}
|
||||
for _, t := range tags {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(applyTags)
|
||||
|
||||
tagJSON, err := json.Marshal(applyTags)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] setting tags for message %s", id)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = sqlf.Update("mailbox").
|
||||
Set("Tags", string(tagJSON)).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Find tags set via --tags in raw message.
|
||||
// Returns a comma-separated string.
|
||||
func findTagsInRawMessage(message *[]byte) string {
|
||||
tagStr := ""
|
||||
if len(config.SMTPTags) == 0 {
|
||||
return tagStr
|
||||
}
|
||||
|
||||
str := strings.ToLower(string(*message))
|
||||
for _, t := range config.SMTPTags {
|
||||
if strings.Contains(str, t.Match) {
|
||||
tagStr += "," + t.Tag
|
||||
}
|
||||
}
|
||||
|
||||
return tagStr
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given database ID
|
||||
// Used when parsing a raw email.
|
||||
func getMessageTags(id string) []string {
|
||||
tags := []string{}
|
||||
var data string
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Tags`).To(&data).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
return tags
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return tags
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
|
||||
func uniqueTagsFromString(s string) []string {
|
||||
tags := []string{}
|
||||
|
||||
if s == "" {
|
||||
return tags
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
w := tools.CleanTag(p)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if config.ValidTagRegexp.MatchString(w) {
|
||||
if !inArray(w, tags) {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
43
internal/storage/tags_test.go
Normal file
43
internal/storage/tags_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing tags")
|
||||
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := SetTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
}
|
||||
}
|
||||
}
|
||||
57
internal/storage/test_shared.go
Normal file
57
internal/storage/test_shared.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
testTextEmail []byte
|
||||
testMimeEmail []byte
|
||||
testRuns = 100
|
||||
)
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if total != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Delivered-To: recipient@example.com
|
||||
Delivered-To: recipient2@example.com
|
||||
Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs;
|
||||
Tue, 26 Jul 2022 20:42:36 -0700 (PDT)
|
||||
X-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881;
|
||||
@@ -23,18 +23,18 @@ ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc
|
||||
uSfA==
|
||||
ARC-Authentication-Results: i=1; mx.google.com;
|
||||
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
|
||||
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
|
||||
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
|
||||
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
|
||||
Return-Path: <sender@example.com>
|
||||
Return-Path: <sender2@example.com>
|
||||
Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])
|
||||
by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35
|
||||
for <recipient@example.com>
|
||||
for <recipient2@example.com>
|
||||
(Google Transport Security);
|
||||
Tue, 26 Jul 2022 20:42:35 -0700 (PDT)
|
||||
Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
|
||||
Received-SPF: pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
|
||||
Authentication-Results: mx.google.com;
|
||||
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
|
||||
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
|
||||
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
|
||||
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=gmail.com; s=20210112;
|
||||
@@ -63,10 +63,10 @@ X-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77
|
||||
X-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ==
|
||||
X-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675;
|
||||
Tue, 26 Jul 2022 20:42:34 -0700 (PDT)
|
||||
Return-Path: <sender@example.com>
|
||||
Return-Path: <sender2@example.com>
|
||||
Received: from [192.168.1.2] ([8.8.8.8])
|
||||
by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32
|
||||
for <recipient@example.com>
|
||||
for <recipient2@example.com>
|
||||
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
|
||||
Tue, 26 Jul 2022 20:42:33 -0700 (PDT)
|
||||
Content-Type: multipart/mixed; boundary="------------ae0qIOkrNQLQHe1YyfTsUXrk"
|
||||
@@ -76,8 +76,8 @@ MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
|
||||
Thunderbird/91.11.0
|
||||
Content-Language: en-NZ
|
||||
To: "Recipient Ross" <recipient@example.com>
|
||||
From: Sender Smith <sender@example.com>
|
||||
To: "Recipient Ross" <recipient2@example.com>
|
||||
From: Sender Smith <sender2@example.com>
|
||||
Subject: inline + attachment
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
226
internal/storage/utils.go
Normal file
226
internal/storage/utils.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/k3a/html2text"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
||||
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
|
||||
data, err := env.AddressList(key)
|
||||
if err != nil || data == nil {
|
||||
return []*mail.Address{}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Generate the search text based on some header fields (to, from, subject etc)
|
||||
// and either the stripped HTML body (if exists) or text body
|
||||
func createSearchText(env *enmime.Envelope) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(env.GetHeader("From") + " ")
|
||||
b.WriteString(env.GetHeader("Subject") + " ")
|
||||
b.WriteString(env.GetHeader("To") + " ")
|
||||
b.WriteString(env.GetHeader("Cc") + " ")
|
||||
b.WriteString(env.GetHeader("Bcc") + " ")
|
||||
b.WriteString(env.GetHeader("Reply-To") + " ")
|
||||
b.WriteString(env.GetHeader("Return-Path") + " ")
|
||||
h := strings.TrimSpace(
|
||||
html2text.HTML2TextWithOptions(
|
||||
env.HTML,
|
||||
html2text.WithLinksInnerText(),
|
||||
),
|
||||
)
|
||||
if h != "" {
|
||||
b.WriteString(h + " ")
|
||||
} else {
|
||||
b.WriteString(env.Text + " ")
|
||||
}
|
||||
// add attachment filenames
|
||||
for _, a := range env.Attachments {
|
||||
b.WriteString(a.FileName + " ")
|
||||
}
|
||||
|
||||
d := cleanString(b.String())
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// CleanString removes unwanted characters from stored search text and search queries
|
||||
func cleanString(str string) string {
|
||||
// remove/replace new lines
|
||||
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
|
||||
str = re.ReplaceAllString(str, " ")
|
||||
|
||||
// remove duplicate whitespace and trim
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
|
||||
}
|
||||
|
||||
// Auto-prune runs every minute to automatically delete oldest messages
|
||||
// if total is greater than the threshold
|
||||
func dbCron() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
start := time.Now()
|
||||
|
||||
// check if database contains deleted data and has not been in use
|
||||
// for 5 minutes, if so VACUUM
|
||||
currentTime := time.Now()
|
||||
diff := currentTime.Sub(dbLastAction)
|
||||
if dbDataDeleted && diff.Minutes() > 5 {
|
||||
dbDataDeleted = false
|
||||
_, err := db.Exec("VACUUM")
|
||||
if err == nil {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] compressed idle database in %s", elapsed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessages > 0 {
|
||||
q := sqlf.Select("ID").
|
||||
From("mailbox").
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
if err := q.Query(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
if err := tx.Rollback(); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
dbDataDeleted = true
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsFile returns whether a path is a file
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
if strings.ToLower(v) == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// escPercentChar replaces `%` with `%%` for SQL searches
|
||||
func escPercentChar(s string) string {
|
||||
return strings.ReplaceAll(s, "%", "%%")
|
||||
}
|
||||
|
||||
// Escape certain characters in search phrases
|
||||
func escSearch(str string) string {
|
||||
dest := make([]byte, 0, 2*len(str))
|
||||
var escape byte
|
||||
for i := 0; i < len(str); i++ {
|
||||
c := str[i]
|
||||
|
||||
escape = 0
|
||||
|
||||
switch c {
|
||||
case 0: /* Must be escaped for 'mysql' */
|
||||
escape = '0'
|
||||
break
|
||||
case '\n': /* Must be escaped for logs */
|
||||
escape = 'n'
|
||||
break
|
||||
case '\r':
|
||||
escape = 'r'
|
||||
break
|
||||
case '\\':
|
||||
escape = '\\'
|
||||
break
|
||||
case '\'':
|
||||
escape = '\''
|
||||
break
|
||||
case '\032': //十进制26,八进制32,十六进制1a, /* This gives problems on Win32 */
|
||||
escape = 'Z'
|
||||
}
|
||||
|
||||
if escape != 0 {
|
||||
dest = append(dest, '\\', escape)
|
||||
} else {
|
||||
dest = append(dest, c)
|
||||
}
|
||||
}
|
||||
|
||||
return string(dest)
|
||||
}
|
||||
32
internal/tools/argsparser.go
Normal file
32
internal/tools/argsparser.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tools
|
||||
|
||||
import "strings"
|
||||
|
||||
// ArgsParser will split a string by new words and quotes phrases
|
||||
func ArgsParser(s string) []string {
|
||||
args := []string{}
|
||||
sb := &strings.Builder{}
|
||||
quoted := false
|
||||
for _, r := range s {
|
||||
if r == '"' {
|
||||
quoted = !quoted
|
||||
sb.WriteRune(r) // keep '"' otherwise comment this line
|
||||
} else if !quoted && r == ' ' {
|
||||
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
|
||||
if v != "" {
|
||||
args = append(args, v)
|
||||
}
|
||||
sb.Reset()
|
||||
} else {
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
|
||||
if v != "" {
|
||||
args = append(args, v)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
19
internal/tools/html.go
Normal file
19
internal/tools/html.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// GetHTMLAttributeVal returns the value of an HTML Attribute, else an error.
|
||||
// Returns a blank value if the attribute is set but empty.
|
||||
func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
for _, a := range e.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%s not found", key)
|
||||
}
|
||||
59
internal/tools/message.go
Normal file
59
internal/tools/message.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package tools provides various methods for various things
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
// RemoveMessageHeaders scans a message for headers, if found them removes them.
|
||||
// It will only remove a single instance of any header, and is intended to remove
|
||||
// Bcc & Message-Id.
|
||||
func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
|
||||
for _, hdr := range headers {
|
||||
// case-insensitive
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
|
||||
|
||||
// header := []byte(hdr + ":")
|
||||
if m.Header.Get(hdr) != "" {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] removing %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(""), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
25
internal/tools/tags.go
Normal file
25
internal/tools/tags.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, removing whitespace and invalid characters
|
||||
func CleanTag(s string) string {
|
||||
s = strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
return s
|
||||
}
|
||||
45
internal/tools/tools_test.go
Normal file
45
internal/tools/tools_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArgsParser(t *testing.T) {
|
||||
tests := map[string][]string{}
|
||||
tests["this is a test"] = []string{"this", "is", "a", "test"}
|
||||
tests["\"this is\" a test"] = []string{"this is", "a", "test"}
|
||||
tests["!\"this is\" a test"] = []string{"!this is", "a", "test"}
|
||||
tests["subject:this is a test"] = []string{"subject:this", "is", "a", "test"}
|
||||
tests["subject:\"this is\" a test"] = []string{"subject:this is", "a", "test"}
|
||||
tests["subject:\"this is\" \"a test\""] = []string{"subject:this is", "a test"}
|
||||
tests["subject:\"this 'is\" \"a test\""] = []string{"subject:this 'is", "a test"}
|
||||
tests["subject:\"this 'is a test"] = []string{"subject:this 'is a test"}
|
||||
tests["\"this is a test\"=\"this is a test\""] = []string{"this is a test=this is a test"}
|
||||
|
||||
for search, expected := range tests {
|
||||
res := ArgsParser(search)
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Log("Args parser error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTag(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
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 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"
|
||||
|
||||
for search, expected := range tests {
|
||||
res := CleanTag(search)
|
||||
if res != expected {
|
||||
t.Log("CleanTags error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
// get the running binary
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = replaceFile(oldExec, newExec); err != nil {
|
||||
@@ -1,43 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
log *logrus.Logger
|
||||
)
|
||||
|
||||
// Log returns the logger instance
|
||||
func Log() *logrus.Logger {
|
||||
if log == nil {
|
||||
log = logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
if config.VerboseLogging {
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
if config.NoLogging {
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
}
|
||||
|
||||
log.Out = os.Stdout
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006/01/02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// PrettyPrint for debugging
|
||||
func PrettyPrint(i interface{}) {
|
||||
s, _ := json.MarshalIndent(i, "", "\t")
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
12
main.go
12
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/cmd"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
@@ -15,10 +16,19 @@ func main() {
|
||||
}
|
||||
|
||||
// running directly
|
||||
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked
|
||||
sendmail.Run()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns a lowercase string stripped of the file extension (if exists).
|
||||
// Used for detecting Windows commands which ignores letter casing and `.exe`.
|
||||
// eg: "MaIlpIT.Exe" returns "mailpit"
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
return strings.TrimSuffix(s, filepath.Ext(s))
|
||||
}
|
||||
|
||||
3378
package-lock.json
generated
3378
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -3,23 +3,33 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.js",
|
||||
"watch": "WATCH=true node esbuild.config.js",
|
||||
"package": "MINIFY=true node esbuild.config.js"
|
||||
"build": "node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"axios": "^1.2.1",
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"moment": "^2.29.4",
|
||||
"remove": "^0.1.5",
|
||||
"vue": "^3.2.13"
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.14.50",
|
||||
"esbuild-plugin-sass": "^1.0.1",
|
||||
"esbuild-plugin-vue-next": "^0.1.4"
|
||||
"esbuild": "^0.19.1",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^2.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,24 +1,47 @@
|
||||
// Package cmd is the sendmail cli
|
||||
package cmd
|
||||
|
||||
/**
|
||||
* Bare bones sendmail drop-in replacement borrowed from MailHog
|
||||
*
|
||||
* It uses a bit of a hack for flag parsing in order to be compatible
|
||||
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
|
||||
* is not POSIX compatible.
|
||||
*
|
||||
* The -bs command-line switch causes sendmail to run a single SMTP session in the
|
||||
* foreground over its standard input and output, and then exit. The SMTP session
|
||||
* is exactly like a network SMTP session. Usually, one or more messages are
|
||||
* submitted to sendmail for delivery.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/reiver/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
var (
|
||||
// SMTPAddr address
|
||||
SMTPAddr = "localhost:1025"
|
||||
// FromAddr email address
|
||||
FromAddr string
|
||||
|
||||
// UseB - used to set from `-bs`
|
||||
UseB bool
|
||||
// UseS - used to set from `-bs`
|
||||
UseS bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
host = "localhost"
|
||||
@@ -30,36 +53,76 @@ func Run() {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
fromAddr := username + "@" + host
|
||||
smtpAddr := "localhost:1025"
|
||||
var recip []string
|
||||
if FromAddr == "" {
|
||||
FromAddr = username + "@" + host
|
||||
}
|
||||
}
|
||||
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
var recipients []string
|
||||
|
||||
// defaults from envars if provided
|
||||
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
|
||||
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
|
||||
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
}
|
||||
|
||||
var verbose bool
|
||||
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
|
||||
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
|
||||
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolP("verbose", "v", false, "Ignored")
|
||||
flag.BoolP("long-i", "i", false, "Ignored")
|
||||
flag.BoolP("long-o", "o", false, "Ignored")
|
||||
flag.BoolP("long-t", "t", false, "Ignored")
|
||||
|
||||
// set the default help
|
||||
flag.Usage = func() {
|
||||
fmt.Println(HelpTemplate(os.Args[0:1]))
|
||||
}
|
||||
|
||||
var showHelp bool
|
||||
// avoid 'pflag: help requested' error
|
||||
flag.BoolVarP(&showHelp, "help", "h", false, "")
|
||||
|
||||
// override defaults from cli flags
|
||||
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
|
||||
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
|
||||
flag.BoolP("long-i", "i", true, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-t", "t", true, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
flag.Parse()
|
||||
|
||||
// allow recipient to be passed as an argument
|
||||
recip = flag.Args()
|
||||
// allow recipients to be passed as an argument
|
||||
recipients = flag.Args()
|
||||
|
||||
if verbose {
|
||||
fmt.Fprintln(os.Stderr, smtpAddr, fromAddr)
|
||||
// if run via `mailpit sendmail ...` then remove `sendmail` from "recipients"
|
||||
if len(recipients) > 0 && recipients[0] == "sendmail" {
|
||||
recipients = recipients[1:]
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(os.Stdin)
|
||||
if showHelp {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ensure -bs is set
|
||||
if UseB && !UseS || !UseB && UseS {
|
||||
fmt.Printf("error: use -bs")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// handles `sendmail -bs`
|
||||
if UseB && UseS {
|
||||
var caller telnet.Caller = telnet.StandardCaller
|
||||
|
||||
// telnet directly to SMTP
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading stdin")
|
||||
os.Exit(11)
|
||||
@@ -67,19 +130,55 @@ func Run() {
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("error parsing message body: %s", err))
|
||||
fmt.Fprintf(os.Stderr, "error parsing message body: %si\n", err)
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
if len(recip) == 0 {
|
||||
// We only need to parse the message to get a recipient if none where
|
||||
// provided on the command line.
|
||||
recip = append(recip, msg.Header.Get("To"))
|
||||
addresses := []string{}
|
||||
|
||||
if len(recipients) > 0 {
|
||||
addresses = recipients
|
||||
} else {
|
||||
// get all recipients in To, Cc and Bcc
|
||||
if to, err := msg.Header.AddressList("To"); err == nil {
|
||||
for _, a := range to {
|
||||
addresses = append(addresses, a.Address)
|
||||
}
|
||||
}
|
||||
if cc, err := msg.Header.AddressList("Cc"); err == nil {
|
||||
for _, a := range cc {
|
||||
addresses = append(addresses, a.Address)
|
||||
}
|
||||
}
|
||||
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
|
||||
for _, a := range bcc {
|
||||
addresses = append(addresses, a.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
|
||||
err = smtp.SendMail(SMTPAddr, nil, FromAddr, addresses, body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
log.Fatal(err)
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// HelpTemplate returns a string of the help
|
||||
func HelpTemplate(args []string) string {
|
||||
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
|
||||
|
||||
Usage: %s [flags] [recipients] < message
|
||||
|
||||
See: https://github.com/axllent/mailpit
|
||||
|
||||
Flags:
|
||||
-S string SMTP server address (default "localhost:1025")
|
||||
-f string Set the envelope sender address (default "%s")
|
||||
-bs Handle SMTP commands on standard input
|
||||
-t Ignored
|
||||
-i Ignored
|
||||
-o Ignored
|
||||
-v Ignored
|
||||
`, config.Version, strings.Join(args, " "), FromAddr)
|
||||
}
|
||||
|
||||
224
server/api.go
224
server/api.go
@@ -1,224 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/data"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type messagesResult struct {
|
||||
Total int `json:"total"`
|
||||
Unread int `json:"unread"`
|
||||
Count int `json:"count"`
|
||||
Start int `json:"start"`
|
||||
Items []data.Summary `json:"items"`
|
||||
}
|
||||
|
||||
// Return a list of available mailboxes
|
||||
func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
|
||||
res, err := storage.ListMailboxes()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
if !storage.MailboxExists(mailbox) {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(mailbox, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet(mailbox)
|
||||
|
||||
var res messagesResult
|
||||
|
||||
res.Start = start
|
||||
res.Items = messages
|
||||
res.Count = len(res.Items)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
if !storage.MailboxExists(mailbox) {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
// we will only return up to 200 results
|
||||
start := 0
|
||||
limit := 200
|
||||
|
||||
messages, err := storage.Search(mailbox, search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet(mailbox)
|
||||
|
||||
var res messagesResult
|
||||
|
||||
res.Start = start
|
||||
res.Items = messages
|
||||
res.Count = len(messages)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Open a message
|
||||
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Download/view an attachment
|
||||
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
a, err := storage.GetAttachmentPart(mailbox, id, partID)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// View the full email source as plain text
|
||||
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
data, err := storage.GetMessageRaw(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// Delete all messages in the mailbox
|
||||
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
err := storage.DeleteAllMessages(mailbox)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Delete a single message
|
||||
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
err := storage.DeleteOneMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
err := storage.UnreadMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
}
|
||||
852
server/apiv1/api.go
Normal file
852
server/apiv1/api.go
Normal file
@@ -0,0 +1,852 @@
|
||||
// Package apiv1 handles all the API responses
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/gorilla/mux"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/messages messages GetMessages
|
||||
//
|
||||
// # List messages
|
||||
//
|
||||
// Returns messages from the mailbox ordered from newest to oldest.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: Limit results
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// default: ErrorResponse
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Search returns the latest messages as JSON
|
||||
func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/search messages MessagesSummary
|
||||
//
|
||||
// # Search messages
|
||||
//
|
||||
// Returns the latest messages matching a search.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: Limit results
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// default: ErrorResponse
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = results
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/search messages MessagesSummary
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Deletes messages matching a search.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.DeleteSearch(search); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// GetMessage (method: GET) returns the Message as JSON
|
||||
func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID} message Message
|
||||
//
|
||||
// # Get message summary
|
||||
//
|
||||
// Returns the summary of a message, marking the message as read.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: Message
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
|
||||
//
|
||||
// # Get message attachment
|
||||
//
|
||||
// This will return the attachment part using the appropriate Content-Type.
|
||||
//
|
||||
// Produces:
|
||||
// - application/*
|
||||
// - image/*
|
||||
// - text/*
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: BinaryResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// GetHeaders (method: GET) returns the message headers as JSON
|
||||
func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/headers message Headers
|
||||
//
|
||||
// # Get message headers
|
||||
//
|
||||
// Returns the message headers as an array.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessageHeaders
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(m.Header)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DownloadRaw (method: GET) returns the full email source as plain text
|
||||
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/raw message Raw
|
||||
//
|
||||
// # Get message source
|
||||
//
|
||||
// Returns the full email source as plain text.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
|
||||
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/messages messages Delete
|
||||
//
|
||||
// # Delete messages
|
||||
//
|
||||
// If no IDs are provided then all messages are deleted.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Database IDs to delete
|
||||
// required: false
|
||||
// type: DeleteRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var data struct {
|
||||
IDs []string
|
||||
}
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil || len(data.IDs) == 0 {
|
||||
if err := storage.DeleteAllMessages(); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, id := range data.IDs {
|
||||
if err := storage.DeleteOneMessage(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
// If no IDs are provided then all messages are updated.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatus
|
||||
//
|
||||
// # Set read status
|
||||
//
|
||||
// If no IDs are provided then all messages are updated.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Database IDs to update
|
||||
// required: false
|
||||
// type: SetReadStatusRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
|
||||
if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := storage.MarkAllUnread()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkUnread(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// GetTags (method: GET) will get all tags currently in use
|
||||
func GetTags(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/tags tags SetTags
|
||||
//
|
||||
// # Get all current tags
|
||||
//
|
||||
// Returns a JSON array of all unique message tags.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ArrayResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
tags := storage.GetAllTags()
|
||||
|
||||
data, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// SetTags (method: PUT) will set the tags for all provided IDs
|
||||
func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/tags tags SetTags
|
||||
//
|
||||
// # Set message tags
|
||||
//
|
||||
// To remove all tags from a message, pass an empty tags array.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Database IDs to update
|
||||
// required: true
|
||||
// type: SetTagsRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Tags []string
|
||||
IDs []string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
|
||||
if len(ids) > 0 {
|
||||
for _, id := range ids {
|
||||
if err := storage.SetTags(id, data.Tags); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
// If no IDs are provided then all messages are updated.
|
||||
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route POST /api/v1/message/{ID}/release message Release
|
||||
//
|
||||
// # Release message
|
||||
//
|
||||
// Release a message via a pre-configured external SMTP server..
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: to
|
||||
// in: body
|
||||
// description: Array of email addresses to release message to
|
||||
// required: true
|
||||
// type: ReleaseMessageRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := releaseMessageRequest{}
|
||||
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tos := data.To
|
||||
if len(tos) == 0 {
|
||||
httpError(w, "No valid addresses found")
|
||||
return
|
||||
}
|
||||
|
||||
for _, to := range tos {
|
||||
address, err := mail.ParseAddress(to)
|
||||
|
||||
if err != nil {
|
||||
httpError(w, "Invalid email address: "+to)
|
||||
return
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
httpError(w, "Mail address does not match allowlist: "+to)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
froms, err := m.Header.AddressList("From")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
from := froms[0].Address
|
||||
|
||||
// if sender is used, then change from to the sender
|
||||
if senders, err := m.Header.AddressList("Sender"); err == nil {
|
||||
from = senders[0].Address
|
||||
}
|
||||
|
||||
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc", "Message-Id"})
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// set the Return-Path and SMTP mfrom
|
||||
if config.SMTPRelayConfig.ReturnPath != "" {
|
||||
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
|
||||
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
|
||||
}
|
||||
|
||||
from = config.SMTPRelayConfig.ReturnPath
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := uuid.NewV4().String() + "@mailpit"
|
||||
// add unique ID
|
||||
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
|
||||
|
||||
if err := smtpd.Send(from, tos, msg); err != nil {
|
||||
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
|
||||
httpError(w, "SMTP error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheckResponse
|
||||
//
|
||||
// # HTML check (beta)
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// LinkCheck returns a summary of links in the email
|
||||
func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheckResponse
|
||||
//
|
||||
// # Link check (beta)
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: follow
|
||||
// in: query
|
||||
// description: Follow redirects
|
||||
// required: false
|
||||
// type: boolean
|
||||
// default: false
|
||||
//
|
||||
// Responses:
|
||||
// 200: LinkCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
f := r.URL.Query().Get("follow")
|
||||
followRedirects := f == "true" || f == "1"
|
||||
|
||||
summary, err := linkcheck.RunTests(msg, followRedirects)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "404 page not found")
|
||||
}
|
||||
|
||||
// HTTPError returns a basic error message (400 response)
|
||||
func httpError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
start = n
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
}
|
||||
|
||||
// GetOptions returns a blank response
|
||||
func GetOptions(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(""))
|
||||
}
|
||||
74
server/apiv1/info.go
Normal file
74
server/apiv1/info.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
)
|
||||
|
||||
// Response includes the current and latest Mailpit version, database info, and memory usage
|
||||
//
|
||||
// swagger:model AppInformation
|
||||
type appInformation struct {
|
||||
// Current Mailpit version
|
||||
Version string
|
||||
// Latest Mailpit version
|
||||
LatestVersion string
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize int64
|
||||
// Total number of messages in the database
|
||||
Messages int
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
}
|
||||
|
||||
// AppInfo returns some basic details about the running app, and latest release.
|
||||
func AppInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
//
|
||||
// # Get application information
|
||||
//
|
||||
// Returns basic runtime information, message totals and latest release version.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: InfoResponse
|
||||
// default: ErrorResponse
|
||||
info := appInformation{}
|
||||
info.Version = config.Version
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.Memory = m.Sys - m.HeapReleased
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
}
|
||||
|
||||
info.Database = config.DataFile
|
||||
|
||||
db, err := os.Stat(info.Database)
|
||||
if err == nil {
|
||||
info.DatabaseSize = db.Size()
|
||||
}
|
||||
|
||||
info.Messages = storage.CountTotal()
|
||||
|
||||
bytes, _ := json.Marshal(info)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
55
server/apiv1/structs.go
Normal file
55
server/apiv1/structs.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
// Total number of messages in mailbox
|
||||
Total int `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
Unread int `json:"unread"`
|
||||
|
||||
// Legacy - now undocumented in API specs but left for backwards compatibility.
|
||||
// Removed from API documentation 2023-07-12
|
||||
// swagger:ignore
|
||||
Count int `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount int `json:"messages_count"`
|
||||
|
||||
// // Number of results returned on current page
|
||||
// Count int `json:"count"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
// All current tags
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// Messages summary
|
||||
// in:body
|
||||
Messages []storage.MessageSummary `json:"messages"`
|
||||
}
|
||||
|
||||
// The following structs & aliases are provided for easy import
|
||||
// and understanding of the JSON structure.
|
||||
|
||||
// MessageSummary - summary of a single message
|
||||
type MessageSummary = storage.MessageSummary
|
||||
|
||||
// Message data
|
||||
type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
|
||||
// HTMLCheckResponse summary
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// LinkCheckResponse summary
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
19
server/apiv1/swagger-config.yml
Normal file
19
server/apiv1/swagger-config.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
consumes:
|
||||
- application/json
|
||||
info:
|
||||
description: |-
|
||||
OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).
|
||||
title: Mailpit API
|
||||
contact:
|
||||
name: GitHub
|
||||
url: https://github.com/axllent/mailpit
|
||||
license:
|
||||
name: MIT license
|
||||
url: https://github.com/axllent/mailpit/blob/develop/LICENSE
|
||||
version: "v1"
|
||||
paths: {}
|
||||
produces:
|
||||
- application/json
|
||||
schemes:
|
||||
- http
|
||||
swagger: "2.0"
|
||||
109
server/apiv1/swagger.go
Normal file
109
server/apiv1/swagger.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package apiv1
|
||||
|
||||
// These structs are for the purpose of defining swagger HTTP responses
|
||||
|
||||
// Application information
|
||||
// swagger:response InfoResponse
|
||||
type infoResponse struct {
|
||||
// Application information
|
||||
Body appInformation
|
||||
}
|
||||
|
||||
// Web UI configuration
|
||||
// swagger:response WebUIConfigurationResponse
|
||||
type webUIConfigurationResponse struct {
|
||||
// Web UI configuration settings
|
||||
Body webUIConfiguration
|
||||
}
|
||||
|
||||
// Message summary
|
||||
// swagger:response MessagesSummaryResponse
|
||||
type messagesSummaryResponse struct {
|
||||
// The message summary
|
||||
// in: body
|
||||
Body MessagesSummary
|
||||
}
|
||||
|
||||
// Message headers
|
||||
// swagger:model MessageHeaders
|
||||
type messageHeaders map[string][]string
|
||||
|
||||
// Delete request
|
||||
// swagger:model DeleteRequest
|
||||
type deleteRequest struct {
|
||||
// ids
|
||||
// in:body
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// Set read status request
|
||||
// swagger:model SetReadStatusRequest
|
||||
type setReadStatusRequest struct {
|
||||
// Read status
|
||||
Read bool `json:"read"`
|
||||
|
||||
// ids
|
||||
// in:body
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// Set tags request
|
||||
// swagger:model SetTagsRequest
|
||||
type setTagsRequest struct {
|
||||
// Tags
|
||||
// in:body
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// IDs
|
||||
// in:body
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// Release request
|
||||
// swagger:model ReleaseMessageRequest
|
||||
type releaseMessageRequest struct {
|
||||
// To
|
||||
// in:body
|
||||
To []string `json:"to"`
|
||||
}
|
||||
|
||||
// Binary data response inherits the attachment's content type
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse struct {
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// Plain text response
|
||||
// swagger:response TextResponse
|
||||
type textResponse struct {
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// HTML response
|
||||
// swagger:response HTMLResponse
|
||||
type htmlResponse struct {
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error response
|
||||
// swagger:response ErrorResponse
|
||||
type errorResponse struct {
|
||||
// The error message
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// Plain text "ok" response
|
||||
// swagger:response OKResponse
|
||||
type okResponse struct {
|
||||
// Default response
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
|
||||
// Plain JSON array response
|
||||
// swagger:response ArrayResponse
|
||||
type arrayResponse []string
|
||||
134
server/apiv1/thumbnails.go
Normal file
134
server/apiv1/thumbnails.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
thumbWidth = 180
|
||||
thumbHeight = 120
|
||||
)
|
||||
|
||||
// Thumbnail returns a thumbnail image for an attachment (images only)
|
||||
func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
|
||||
//
|
||||
// # Get an attachment image thumbnail
|
||||
//
|
||||
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
|
||||
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
|
||||
//
|
||||
// Produces:
|
||||
// - image/jpeg
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: BinaryResponse
|
||||
// default: ErrorResponse
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.ContentType, "image/") {
|
||||
blankImage(a, w)
|
||||
return
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(a.Content)
|
||||
|
||||
img, err := imaging.Decode(buf)
|
||||
if err != nil {
|
||||
// it's not an image, return default
|
||||
logger.Log().Warning(err)
|
||||
blankImage(a, w)
|
||||
return
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
foo := bufio.NewWriter(&b)
|
||||
|
||||
var dstImageFill *image.NRGBA
|
||||
|
||||
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
|
||||
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
|
||||
} else {
|
||||
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
|
||||
}
|
||||
// create white image and paste image over the top
|
||||
// preventing black backgrounds for transparent GIF/PNG images
|
||||
dst := imaging.New(thumbWidth, thumbHeight, color.White)
|
||||
// paste the original over the top
|
||||
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
|
||||
|
||||
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
|
||||
logger.Log().Warning(err)
|
||||
blankImage(a, w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(b.Bytes())
|
||||
}
|
||||
|
||||
// Return a blank image instead of an error when file or image not supported
|
||||
func blankImage(a *enmime.Part, w http.ResponseWriter) {
|
||||
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
|
||||
img := image.NewRGBA(rect)
|
||||
background := color.RGBA{255, 255, 255, 255}
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
|
||||
var b bytes.Buffer
|
||||
foo := bufio.NewWriter(&b)
|
||||
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
|
||||
|
||||
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
|
||||
logger.Log().Warning(err)
|
||||
}
|
||||
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(b.Bytes())
|
||||
}
|
||||
63
server/apiv1/webui.go
Normal file
63
server/apiv1/webui.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
// Response includes global web UI settings
|
||||
//
|
||||
// swagger:model WebUIConfiguration
|
||||
type webUIConfiguration struct {
|
||||
// Message Relay information
|
||||
MessageRelay struct {
|
||||
// Whether message relaying (release) is enabled
|
||||
Enabled bool
|
||||
// The configured SMTP server address
|
||||
SMTPServer string
|
||||
// Enforced Return-Path (if set) for relay bounces
|
||||
ReturnPath string
|
||||
// Allowlist of accepted recipients
|
||||
RecipientAllowlist string
|
||||
}
|
||||
|
||||
// Whether the HTML check has been globally disabled
|
||||
DisableHTMLCheck bool
|
||||
}
|
||||
|
||||
// WebUIConfig returns configuration settings for the web UI.
|
||||
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/webui application WebUIConfiguration
|
||||
//
|
||||
// # Get web UI configuration
|
||||
//
|
||||
// Returns configuration settings for the web UI.
|
||||
// Intended for web UI only!
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: WebUIConfigurationResponse
|
||||
// default: ErrorResponse
|
||||
conf := webUIConfiguration{}
|
||||
|
||||
conf.MessageRelay.Enabled = config.ReleaseEnabled
|
||||
if config.ReleaseEnabled {
|
||||
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
|
||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
8
server/handlers/k8healthz.go
Normal file
8
server/handlers/k8healthz.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HealthzHandler is a liveness probe
|
||||
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
17
server/handlers/k8sready.go
Normal file
17
server/handlers/k8sready.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic
|
||||
func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
if isReady == nil || !isReady.Load().(bool) {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
163
server/handlers/message-rendered.go
Normal file
163
server/handlers/message-rendered.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
|
||||
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.html testing GetMessageHTML
|
||||
//
|
||||
// # Render message HTML part
|
||||
//
|
||||
// Renders just the message's HTML part which can be used for UI integration testing.
|
||||
// Attached inline images are modified to link to the API provided they exist.
|
||||
// Note that is the message does not contain a HTML part then an 404 error is returned.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/html
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
messages, err := storage.List(0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
id = messages[0].ID
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
if msg.HTML == "" {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "This message does not contain a HTML part")
|
||||
return
|
||||
}
|
||||
|
||||
html := linkInlinedImages(msg)
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// GetMessageText (method: GET) returns a message's text part
|
||||
func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.txt testing GetMessageText
|
||||
//
|
||||
// # Render message text part
|
||||
//
|
||||
// Renders just the message's text part which can be used for UI integration testing.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
messages, err := storage.List(0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
id = messages[0].ID
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(msg.Text))
|
||||
}
|
||||
|
||||
// This will remap all attachment images with relative paths
|
||||
func linkInlinedImages(msg *storage.Message) string {
|
||||
html := msg.HTML
|
||||
|
||||
for _, a := range msg.Inline {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range msg.Attachments {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
147
server/handlers/proxy.go
Normal file
147
server/handlers/proxy.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package handlers contains a specific handlers
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
// ProxyHandler is used to proxy assets for printing
|
||||
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
uri := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
if uri == "" {
|
||||
logger.Log().Warn("[proxy] URL missing")
|
||||
httpError(w, "Error: URL missing")
|
||||
return
|
||||
}
|
||||
|
||||
if !linkRe.MatchString(uri) {
|
||||
logger.Log().Warnf("[proxy] invalid URL %s", uri)
|
||||
httpError(w, "Error: invalid URL")
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// use requesting useragent
|
||||
req.Header.Set("User-Agent", r.UserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// relay common headers
|
||||
if resp.Header.Get("content-type") != "" {
|
||||
w.Header().Set("content-type", resp.Header.Get("content-type"))
|
||||
}
|
||||
if resp.Header.Get("last-modified") != "" {
|
||||
w.Header().Set("last-modified", resp.Header.Get("last-modified"))
|
||||
}
|
||||
if resp.Header.Get("content-disposition") != "" {
|
||||
w.Header().Set("content-disposition", resp.Header.Get("content-disposition"))
|
||||
}
|
||||
if resp.Header.Get("cache-control") != "" {
|
||||
w.Header().Set("cache-control", resp.Header.Get("cache-control"))
|
||||
}
|
||||
|
||||
// replace url() values with proxy address, eg: fonts & images
|
||||
if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") {
|
||||
var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`)
|
||||
body = re.ReplaceAllFunc(body, func(s []byte) []byte {
|
||||
parts := re.FindStringSubmatch(string(s))
|
||||
|
||||
// don't resolve inline `data:..`
|
||||
if strings.HasPrefix(parts[3], "data:") {
|
||||
return []byte(parts[3])
|
||||
}
|
||||
|
||||
address, err := absoluteURL(parts[3], uri)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
return []byte(parts[3])
|
||||
}
|
||||
|
||||
return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")")
|
||||
})
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode)
|
||||
|
||||
// relay status code - WriteHeader must come after Header.Set()
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
|
||||
func absoluteURL(link, baseURL string) (string, error) {
|
||||
// scheme relative links, eg <script src="//example.com/script.js">
|
||||
if len(link) > 1 && link[0:2] == "//" {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
link = base.Scheme + ":" + link
|
||||
}
|
||||
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
|
||||
// remove hashes
|
||||
u.Fragment = ""
|
||||
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
|
||||
result := base.ResolveReference(u)
|
||||
|
||||
// ensure link is HTTP(S)
|
||||
if result.Scheme != "http" && result.Scheme != "https" {
|
||||
return link, fmt.Errorf("Invalid URL: %s", result.String())
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// HTTPError returns a basic error message (400 response)
|
||||
func httpError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
}
|
||||
219
server/server.go
219
server/server.go
@@ -1,19 +1,24 @@
|
||||
// Package server is the HTTP daemon
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/server/handlers"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -21,8 +26,14 @@ import (
|
||||
//go:embed ui
|
||||
var embeddedFS embed.FS
|
||||
|
||||
// AccessControlAllowOrigin CORS policy
|
||||
var AccessControlAllowOrigin string
|
||||
|
||||
// Listen will start the httpd
|
||||
func Listen() {
|
||||
isReady := &atomic.Value{}
|
||||
isReady.Store(false)
|
||||
|
||||
serverRoot, err := fs.Sub(embeddedFS, "ui")
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[http] %s", err)
|
||||
@@ -33,33 +44,91 @@ func Listen() {
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/api/mailboxes", middleWareFunc(apiListMailboxes))
|
||||
r.HandleFunc("/api/{mailbox}/messages", middleWareFunc(apiListMailbox))
|
||||
r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox))
|
||||
r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll))
|
||||
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
|
||||
r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/unread", middleWareFunc(apiUnreadOne))
|
||||
r.HandleFunc("/api/{mailbox}/{id}", middleWareFunc(apiOpenMessage))
|
||||
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
|
||||
r := apiRoutes()
|
||||
|
||||
// kubernetes probes
|
||||
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
|
||||
|
||||
// proxy handler for screenshots
|
||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||
|
||||
// virtual filesystem for /dist/ & some individual files
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
|
||||
// redirect to webroot if no trailing slash
|
||||
if config.Webroot != "/" {
|
||||
redirect := strings.TrimRight(config.Webroot, "/")
|
||||
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
||||
}
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
|
||||
|
||||
// put it all together
|
||||
http.Handle("/", r)
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
logger.Log().Info("[http] enabling web UI basic authentication")
|
||||
}
|
||||
|
||||
if config.UISSLCert != "" && config.UISSLKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
|
||||
// Mark the application here as ready
|
||||
isReady.Store(true)
|
||||
|
||||
if config.UITLSCert != "" && config.UITLSKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
}
|
||||
}
|
||||
|
||||
func apiRoutes() *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
|
||||
// API V1
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetTags)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
if !config.DisableHTMLCheck {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||
}
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
// return blank 200 response for OPTIONS requests for CORS
|
||||
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// BasicAuthResponse returns an basic auth response to the browser
|
||||
func basicAuthResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
|
||||
@@ -80,6 +149,15 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
// and gzip compression.
|
||||
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
@@ -110,6 +188,14 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
// and gzip compression
|
||||
func middlewareHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
@@ -136,34 +222,83 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "404 page not found")
|
||||
// Redirect to webroot
|
||||
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
}
|
||||
|
||||
// HTTPError returns a basic error message (400 response)
|
||||
func httpError(w http.ResponseWriter, msg string) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
storage.BroadcastMailboxStats()
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
start = n
|
||||
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
|
||||
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
if config.Webroot != "/" {
|
||||
// artificially inject a path at the start
|
||||
replacement := fmt.Sprintf("{\n \"basePath\": \"%s\",", strings.TrimRight(config.Webroot, "/"))
|
||||
|
||||
f = bytes.Replace(f, []byte("{"), []byte(replacement), 1)
|
||||
}
|
||||
|
||||
return start, limit
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(f)
|
||||
}
|
||||
|
||||
// Just returns the default HTML template
|
||||
func index(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
var h = `<!DOCTYPE html>
|
||||
<html lang="en" class="h-100">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<link rel="icon" href="{{ .Webroot }}favicon.svg">
|
||||
<title>Mailpit</title>
|
||||
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
|
||||
</head>
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
|
||||
<noscript>You require JavaScript to use this app.</noscript>
|
||||
</div>
|
||||
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
|
||||
t, err := template.New("index").Parse(h)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Webroot string
|
||||
Version string
|
||||
}{
|
||||
Webroot: config.Webroot,
|
||||
Version: config.Version,
|
||||
}
|
||||
|
||||
buff := new(bytes.Buffer)
|
||||
|
||||
err = t.Execute(buff, data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buff.Bytes()
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
370
server/server_test.go
Normal file
370
server/server_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
putDataStruct struct {
|
||||
Read bool `json:"read"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
)
|
||||
|
||||
func TestAPIv1Messages(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// store this for later tests
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 messages
|
||||
t.Log("Read first 10 messages including raw & headers")
|
||||
putIDS := []string{}
|
||||
for idx, msg := range m.Messages {
|
||||
if idx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// get RAW
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// het headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
|
||||
// 10 should be marked as read
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// delete all
|
||||
t.Log("Delete all messages")
|
||||
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, received %s", err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
}
|
||||
|
||||
func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 IDs
|
||||
t.Log("Get first 10 IDs")
|
||||
putIDS := []string{}
|
||||
for idx, msg := range m.Messages {
|
||||
if idx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark first 10 as unread
|
||||
t.Log("Mark first 10 as read")
|
||||
putData := putDataStruct
|
||||
putData.Read = true
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// mark first 10 as read
|
||||
t.Log("Mark first 10 as unread")
|
||||
putData.Read = false
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark all as read
|
||||
putData.Read = true
|
||||
putData.IDs = []string{}
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
t.Log("Mark all read")
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
|
||||
}
|
||||
|
||||
func TestAPIv1Search(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// search
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-from:from-1@example.com", 99)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
|
||||
}
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
data, err := clientGet(uri)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, unread, m.Unread, "wrong unread count")
|
||||
assertEqual(t, total, m.Total, "wrong total count")
|
||||
}
|
||||
|
||||
func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
t.Logf("Test search: %s", query)
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
limit := fmt.Sprintf("%d", count)
|
||||
|
||||
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.MessagesCount, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := storage.Store(buf.Bytes()); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
data, err := clientGet(url)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func clientGet(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientDelete(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("DELETE", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPut(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("PUT", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
147
server/smtpd/smtp.go
Normal file
147
server/smtpd/smtp.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func allowedRecipients(to []string) []string {
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
|
||||
return to
|
||||
}
|
||||
|
||||
var ar []string
|
||||
|
||||
for _, recipient := range to {
|
||||
address, err := mail.ParseAddress(recipient)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid email address: %s", recipient)
|
||||
continue
|
||||
}
|
||||
|
||||
if !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.RecipientAllowlist)
|
||||
} else {
|
||||
ar = append(ar, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
return ar
|
||||
}
|
||||
|
||||
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Send(from string, to []string, msg []byte) error {
|
||||
recipients := allowedRecipients(to)
|
||||
|
||||
if len(recipients) == 0 {
|
||||
return errors.New("no valid recipients")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if config.SMTPRelayConfig.STARTTLS {
|
||||
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host}
|
||||
|
||||
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
auth := relayAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP 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(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("Unknown fromServer")
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
236
server/smtpd/smtpd.go
Normal file
236
server/smtpd/smtpd.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Package smtpd is the SMTP daemon
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/mhale/smtpd"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
if !config.SMTPStrictRFCHeaders {
|
||||
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
|
||||
|
||||
// add a message ID if not set
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
messageID = uuid.NewV4().String() + "@mailpit"
|
||||
// add unique ID
|
||||
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
|
||||
} else if config.IgnoreDuplicateIDs {
|
||||
if storage.MessageIDExists(messageID) {
|
||||
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// if enabled, this will route the email 1:1 through to the preconfigured smtp server
|
||||
if config.SMTPRelayAllIncoming {
|
||||
if err := Send(from, to, data); err != nil {
|
||||
logger.Log().Warnf("[smtp] error relaying message: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] relayed message from %s via %s:%d", from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
|
||||
missingAddresses := []string{}
|
||||
for _, a := range to {
|
||||
// loop through passed email addresses to check if they are in the headers
|
||||
if _, err := mail.ParseAddress(a); err == nil {
|
||||
_, ok := emails[strings.ToLower(a)]
|
||||
if !ok {
|
||||
missingAddresses = append(missingAddresses, a)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
|
||||
if len(missingAddresses) > 0 {
|
||||
if hasBccHeader {
|
||||
// email already has Bcc header, add to existing addresses
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
|
||||
})
|
||||
|
||||
} else {
|
||||
// prepend new Bcc header
|
||||
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
|
||||
data = append(bcc, data...)
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
|
||||
}
|
||||
|
||||
_, err = storage.Store(data)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
subject := msg.Header.Get("Subject")
|
||||
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
|
||||
allow := config.SMTPAuthConfig.Match(string(username), string(password))
|
||||
if allow {
|
||||
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
} else {
|
||||
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
}
|
||||
|
||||
return allow, nil
|
||||
}
|
||||
|
||||
// Allow any username and password
|
||||
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
|
||||
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Listen starts the SMTPD server
|
||||
func Listen() error {
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
if config.SMTPAuthFile != "" {
|
||||
logger.Log().Infof("[smtpd] enabling login auth via %s (insecure)", config.SMTPAuthFile)
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling all auth (insecure)")
|
||||
}
|
||||
} else {
|
||||
if config.SMTPAuthFile != "" {
|
||||
logger.Log().Infof("[smtpd] enabling login auth via %s (TLS)", config.SMTPAuthFile)
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling any auth (TLS)")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
|
||||
|
||||
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
|
||||
srv := &smtpd.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
Appname: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
}
|
||||
|
||||
if config.SMTPAuthFile != "" {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthHandler = authHandler
|
||||
srv.AuthRequired = true
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthHandler = authHandlerAny
|
||||
}
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
func cleanIP(i net.Addr) string {
|
||||
parts := strings.Split(i.String(), ":")
|
||||
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// Returns a list of all lowercased emails found in To, Cc and Bcc,
|
||||
// as well as whether there is a Bcc field
|
||||
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
|
||||
emails := make(map[string]bool)
|
||||
hasBccHeader := false
|
||||
|
||||
if recipients, err := h.AddressList("To"); err == nil {
|
||||
for _, r := range recipients {
|
||||
emails[strings.ToLower(r.Address)] = true
|
||||
}
|
||||
}
|
||||
|
||||
if recipients, err := h.AddressList("Cc"); err == nil {
|
||||
for _, r := range recipients {
|
||||
emails[strings.ToLower(r.Address)] = true
|
||||
}
|
||||
}
|
||||
|
||||
recipients, err := h.AddressList("Bcc")
|
||||
if err == nil {
|
||||
for _, r := range recipients {
|
||||
emails[strings.ToLower(r.Address)] = true
|
||||
}
|
||||
|
||||
hasBccHeader = true
|
||||
}
|
||||
|
||||
return emails, hasBccHeader
|
||||
}
|
||||
@@ -1,492 +1,37 @@
|
||||
<script>
|
||||
import commonMixins from './mixins.js'
|
||||
import Message from './templates/Message.vue';
|
||||
import moment from 'moment'
|
||||
import CommonMixins from './mixins/CommonMixins'
|
||||
import Notifications from './components/Notifications.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
|
||||
export default {
|
||||
mixins: [commonMixins],
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
Message
|
||||
Notifications,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPath: window.location.hash,
|
||||
mailbox: "catchall",
|
||||
items: [],
|
||||
limit: 50,
|
||||
total: 0,
|
||||
unread: 0,
|
||||
start: 0,
|
||||
search: "",
|
||||
searching: false,
|
||||
isConnected: false,
|
||||
scrollInPlace: false,
|
||||
message: false,
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false
|
||||
}
|
||||
|
||||
beforeMount() {
|
||||
document.title = document.title + ' - ' + location.hostname
|
||||
mailbox.showTagColors = localStorage.getItem('showTagsColors') == '1'
|
||||
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
mailbox.uiConfig = response.data
|
||||
})
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentPath(v, old) {
|
||||
if (v && v.match(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/)) {
|
||||
this.openMessage();
|
||||
} else {
|
||||
this.message = false;
|
||||
}
|
||||
$route(to, from) {
|
||||
// hide mobile menu on URL change
|
||||
this.hideNav()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPrev: function () {
|
||||
return this.start > 0;
|
||||
},
|
||||
canNext: function () {
|
||||
return this.total > (this.start + this.count);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.currentPath = window.location.hash.slice(1);
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.currentPath = window.location.hash.slice(1);
|
||||
});
|
||||
|
||||
this.notificationsSupported = 'https:' == document.location.protocol
|
||||
&& ("Notification" in window && Notification.permission !== "denied");
|
||||
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
|
||||
|
||||
this.connect();
|
||||
this.loadMessages();
|
||||
},
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
let self = this;
|
||||
let params = {};
|
||||
|
||||
let uri = 'api/'+self.mailbox+'/messages';
|
||||
if (self.search) {
|
||||
self.searching = true;
|
||||
self.items = [];
|
||||
uri = 'api/'+self.mailbox+'/search'
|
||||
self.start = 0; // search is displayed on one page
|
||||
params['query'] = self.search;
|
||||
} else {
|
||||
self.searching = false;
|
||||
params['limit'] = self.limit;
|
||||
if (self.start > 0) {
|
||||
params['start'] = self.start;
|
||||
}
|
||||
}
|
||||
|
||||
self.get(uri, params, function(response){
|
||||
self.total = response.data.total;
|
||||
self.unread = response.data.unread;
|
||||
self.count = response.data.count;
|
||||
self.start = response.data.start;
|
||||
self.items = response.data.items;
|
||||
|
||||
if (!self.scrollInPlace) {
|
||||
let mp = document.getElementById('message-page');
|
||||
if (mp) {
|
||||
mp.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.scrollInPlace = false
|
||||
});
|
||||
},
|
||||
|
||||
doSearch: function(e) {
|
||||
e.preventDefault();
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
reloadMessages: function() {
|
||||
this.search = "";
|
||||
this.start = 0;
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
viewNext: function () {
|
||||
this.start = parseInt(this.start, 10) + parseInt(this.limit, 10);
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
viewPrev: function () {
|
||||
let s = this.start - this.limit;
|
||||
if (s < 0) {
|
||||
s = 0;
|
||||
}
|
||||
this.start = s;
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
openMessage: function(id) {
|
||||
let self = this;
|
||||
let params = {};
|
||||
|
||||
let uri = 'api/' + self.mailbox + '/' + self.currentPath
|
||||
self.get(uri, params, function(response) {
|
||||
for (let i in self.items) {
|
||||
if (self.items[i].ID == self.currentPath) {
|
||||
if (!self.items[i].Read) {
|
||||
self.items[i].Read = true;
|
||||
self.unread--;
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = response.data;
|
||||
// replace inline images
|
||||
if (d.HTML && d.Inline) {
|
||||
for (let i in d.Inline) {
|
||||
let a = d.Inline[i];
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('cid:'+a.ContentID, 'g'),
|
||||
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// replace inline images
|
||||
if (d.HTML && d.Attachments) {
|
||||
for (let i in d.Attachments) {
|
||||
let a = d.Attachments[i];
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('cid:'+a.ContentID, 'g'),
|
||||
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message = d;
|
||||
});
|
||||
},
|
||||
|
||||
deleteAll: function() {
|
||||
let self = this;
|
||||
let uri = 'api/' + self.mailbox + '/delete'
|
||||
self.get(uri, false, function(response) {
|
||||
self.reloadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteOne: function() {
|
||||
let self = this;
|
||||
if (!self.message) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/delete'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
markUnread: function() {
|
||||
let self = this;
|
||||
if (!self.message) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/unread'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
let ws = new WebSocket(wsproto + "://" + document.location.host + "/api/"+this.mailbox+"/events");
|
||||
let self = this;
|
||||
ws.onmessage = function (e) {
|
||||
let response = JSON.parse(e.data);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (self.start < 1) {
|
||||
if (!self.searching) {
|
||||
self.items.unshift(response.Data);
|
||||
if (self.items.length > self.limit) {
|
||||
self.items.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.total++;
|
||||
self.unread++;
|
||||
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
self.isConnected = true;
|
||||
self.loadMessages();
|
||||
}
|
||||
|
||||
ws.onclose = function (e) {
|
||||
self.isConnected = false;
|
||||
|
||||
setTimeout(function () {
|
||||
self.connect(); // reconnect
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close();
|
||||
}
|
||||
},
|
||||
|
||||
getPrimaryEmailTo: function(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address;
|
||||
}
|
||||
|
||||
return '[ Unknown ]';
|
||||
},
|
||||
|
||||
getRelativeCreated: function(message) {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString();
|
||||
},
|
||||
|
||||
browserNotify: function(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let b = message.Subject;
|
||||
let options = {
|
||||
body: message,
|
||||
icon: 'mailpit.png'
|
||||
}
|
||||
new Notification(title, options);
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notification");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
let self = this;
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
// If the user accepts, let's create a notification
|
||||
if (permission === "granted") {
|
||||
self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
|
||||
self.notificationsEnabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light">
|
||||
<div class="col-lg-2 col-md-3 col-auto">
|
||||
<a class="navbar-brand" href="#" v-on:click="reloadMessages">
|
||||
<img src="mailpit.svg" alt="Mailpit">
|
||||
<span class="d-none d-md-inline-block ms-2">Mailpit</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-8" v-if="message">
|
||||
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</button>
|
||||
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-5" v-if="!message && total">
|
||||
<form v-on:submit="doSearch">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model.trim="search" placeholder="Search mailbox">
|
||||
<button class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5 text-end" v-if="!message && total">
|
||||
<select v-model="limit" v-on:change="loadMessages"
|
||||
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<span v-if="searching">
|
||||
<b>{{ formatNumber(items.length) }} results</b>
|
||||
</span>
|
||||
<span v-else>
|
||||
<small>
|
||||
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
|
||||
</small>
|
||||
<button class="btn btn-outline-secondary ms-3 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
v-if="!searching">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" style="overflow-y: auto;">
|
||||
<ul class="list-unstyled mt-3 mb-5">
|
||||
<li v-if="isConnected" title="Messages will auto-load">
|
||||
<i class="bi bi-power text-success"></i>
|
||||
Connected
|
||||
</li>
|
||||
<li v-else title="Messages will auto-load">
|
||||
<i class="bi bi-power text-danger"></i>
|
||||
Disconnected
|
||||
</li>
|
||||
<li class="mt-3">
|
||||
<a class="position-relative ps-0" href="#" v-on:click="reloadMessages">
|
||||
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
Inbox
|
||||
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
|
||||
{{ formatNumber(unread) }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-3">
|
||||
<a v-if="total" href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-3" v-if="notificationsSupported && !notificationsEnabled">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications">
|
||||
<i class="bi bi-bell"></i>
|
||||
Enable alerts
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-5 position-fixed bottom-0">
|
||||
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white py-2">
|
||||
<i class="bi bi-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-10 col-md-9 mh-100 pe-0">
|
||||
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
|
||||
<div class="list-group" v-if="items.length">
|
||||
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)">
|
||||
<div class="col-md-3">
|
||||
<div class="d-md-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
|
||||
<div class="text-truncate d-md-none">
|
||||
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-md-block">
|
||||
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</b>
|
||||
</div>
|
||||
<div class="d-none d-md-block text-truncate text-muted small">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{message.To.length - 1}}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-2 mt-md-0">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="d-none d-md-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-md-block col-2 small text-end text-muted">
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="text-muted py-3">No messages</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
|
||||
</div>
|
||||
<div id="loading" v-if="loading">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(total) }} message<span v-if="total > 1">s</span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView />
|
||||
<Notifications />
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import "./assets/bootstrap.scss";
|
||||
import "./assets/styles.scss";
|
||||
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
|
||||
import "bootstrap";
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
51
server/ui-src/assets/_bootstrap.scss
Normal file
51
server/ui-src/assets/_bootstrap.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
// @import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
@import "bootstrap/scss/card";
|
||||
@import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
// @import "bootstrap/scss/popover";
|
||||
// @import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas";
|
||||
// @import "bootstrap/scss/popover";
|
||||
@import "bootstrap/scss/progress";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1 +1,21 @@
|
||||
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
|
||||
$font-family-sans-serif:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
"Noto Sans",
|
||||
"Liberation Sans",
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$link-decoration: none;
|
||||
$primary: #2c3e50;
|
||||
$list-group-disabled-color: #adb5bd;
|
||||
$enable-negative-margins: true;
|
||||
$body-color-dark: #e7eaed;
|
||||
$offcanvas-border-width: 0;
|
||||
|
||||
49
server/ui-src/assets/bootstrap.scss
vendored
49
server/ui-src/assets/bootstrap.scss
vendored
@@ -1,49 +0,0 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "../../../node_modules/bootstrap/scss/functions";
|
||||
@import "../../../node_modules/bootstrap/scss/variables";
|
||||
@import "../../../node_modules/bootstrap/scss/maps";
|
||||
@import "../../../node_modules/bootstrap/scss/mixins";
|
||||
@import "../../../node_modules/bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "../../../node_modules/bootstrap/scss/root";
|
||||
@import "../../../node_modules/bootstrap/scss/reboot";
|
||||
@import "../../../node_modules/bootstrap/scss/type";
|
||||
@import "../../../node_modules/bootstrap/scss/images";
|
||||
@import "../../../node_modules/bootstrap/scss/containers";
|
||||
@import "../../../node_modules/bootstrap/scss/grid";
|
||||
// @import "../../../node_modules/bootstrap/scss/tables";
|
||||
@import "../../../node_modules/bootstrap/scss/forms";
|
||||
@import "../../../node_modules/bootstrap/scss/buttons";
|
||||
// @import "../../../node_modules/bootstrap/scss/transitions";
|
||||
@import "../../../node_modules/bootstrap/scss/dropdown";
|
||||
@import "../../../node_modules/bootstrap/scss/button-group";
|
||||
@import "../../../node_modules/bootstrap/scss/nav";
|
||||
@import "../../../node_modules/bootstrap/scss/navbar";
|
||||
@import "../../../node_modules/bootstrap/scss/card";
|
||||
// @import "../../../node_modules/bootstrap/scss/accordion";
|
||||
// @import "../../../node_modules/bootstrap/scss/breadcrumb";
|
||||
// @import "../../../node_modules/bootstrap/scss/pagination";
|
||||
@import "../../../node_modules/bootstrap/scss/badge";
|
||||
// @import "../../../node_modules/bootstrap/scss/alert";
|
||||
// @import "../../../node_modules/bootstrap/scss/progress";
|
||||
@import "../../../node_modules/bootstrap/scss/list-group";
|
||||
@import "../../../node_modules/bootstrap/scss/close";
|
||||
// @import "../../../node_modules/bootstrap/scss/toasts";
|
||||
@import "../../../node_modules/bootstrap/scss/modal";
|
||||
// @import "../../../node_modules/bootstrap/scss/tooltip";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
// @import "../../../node_modules/bootstrap/scss/carousel";
|
||||
@import "../../../node_modules/bootstrap/scss/spinners";
|
||||
// @import "../../../node_modules/bootstrap/scss/offcanvas";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
|
||||
// Helpers
|
||||
@import "../../../node_modules/bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "../../../node_modules/bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1,54 +1,475 @@
|
||||
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
|
||||
|
||||
@import "bootstrap";
|
||||
@import "./bootstrap";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 99;
|
||||
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
transition: all 0.2s;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
span {
|
||||
opacity: 0.8;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
&:hover {
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
.nav-tabs .nav-link {
|
||||
@include media-breakpoint-down(xl) {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message.read:not(.active) {
|
||||
// background: $gray-100;
|
||||
color: $gray-500;
|
||||
:not(.text-view) > a:not(.no-icon) {
|
||||
&[href^="http://"],
|
||||
&[href^="https://"]
|
||||
{
|
||||
&:after {
|
||||
content: "\f1c5";
|
||||
display: inline-block;
|
||||
font-family: "bootstrap-icons" !important;
|
||||
font-style: normal;
|
||||
font-weight: normal !important;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#nav-plain-text,
|
||||
.loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
// dark mode adjustments
|
||||
@include color-mode(dark) {
|
||||
.loader {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.property {
|
||||
color: #ee6969;
|
||||
}
|
||||
}
|
||||
|
||||
.about-mailpit {
|
||||
@include media-breakpoint-down(md) {
|
||||
width: var(--bs-offcanvas-width);
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
#nav-plain-text .text-view,
|
||||
#nav-source {
|
||||
white-space: pre;
|
||||
font-family: Courier New, Courier, System, fixed-width;
|
||||
font-size: 0.85em;
|
||||
white-space: pre;
|
||||
font-family: "Courier New", Courier, System, fixed-width;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
#nav-plain-text {
|
||||
white-space: pre-wrap;
|
||||
|
||||
#nav-html-source pre[class*="language-"] code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#nav-plain-text .text-view {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.messageHeaders {
|
||||
margin: 15px 0 0;
|
||||
margin: 15px 0 0;
|
||||
|
||||
th {
|
||||
padding-right: 10px;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
#nav-html {
|
||||
@include media-breakpoint-up(md) {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#preview-html {
|
||||
min-height: 300px;
|
||||
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border: solid $gray-300 1px;
|
||||
}
|
||||
}
|
||||
|
||||
#responsive-view {
|
||||
margin: auto;
|
||||
transition: width 0.5s;
|
||||
position: relative;
|
||||
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border-radius: 35px;
|
||||
box-sizing: content-box;
|
||||
padding-bottom: 76px;
|
||||
padding-top: 54px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: $gray-800;
|
||||
|
||||
iframe {
|
||||
height: 100% !important;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.phone {
|
||||
&::before {
|
||||
border-radius: 5px;
|
||||
background: $gray-600;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-radius: 20px;
|
||||
background: $gray-900;
|
||||
bottom: 20px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 65px;
|
||||
height: 40px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.tablet {
|
||||
&::before {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
bottom: 23px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageHeaders {
|
||||
th {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
body.blur {
|
||||
.privacy {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.card.attachment {
|
||||
color: $gray-800;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 3.5rem;
|
||||
text-align: center;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: $gray-300;
|
||||
|
||||
.bi {
|
||||
font-size: 1.3em;
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.card-body {
|
||||
opacity: 1;
|
||||
background: $gray-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-select.tag-selector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
input {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: text; // html5-tags
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrap5-tags
|
||||
.tags-badge {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#DownloadBtn {
|
||||
@include media-breakpoint-down(sm) {
|
||||
position: static;
|
||||
|
||||
.dropdown-menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ReleaseModal {
|
||||
.form-control.dropdown {
|
||||
div {
|
||||
@extend .form-control;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PrismJS 1.29.0 - modified!
|
||||
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
// color: #000;
|
||||
// background: 0 0;
|
||||
font-size: 0.85em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
pre[class*="language-"] {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
pre[class*="language-"] > code {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
code[class*="language-"] {
|
||||
max-height: inherit;
|
||||
height: inherit;
|
||||
padding: 0 1em;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
// background-color: #fdfdfd;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
:not(pre) > code[class*="language-"] {
|
||||
position: relative;
|
||||
padding: 0.2em;
|
||||
border-radius: 0.3em;
|
||||
color: #c92c2c;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.block-comment,
|
||||
.token.cdata,
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: #7d8b99;
|
||||
}
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
}
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
.token.deleted,
|
||||
.token.function-name,
|
||||
.token.number,
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: #c92c2c;
|
||||
}
|
||||
.token.attr-name,
|
||||
.token.builtin,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.inserted,
|
||||
.token.selector,
|
||||
.token.string {
|
||||
color: #2f9c0a;
|
||||
}
|
||||
.token.entity,
|
||||
.token.operator,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.class-name,
|
||||
.token.keyword {
|
||||
color: #1990b8;
|
||||
}
|
||||
.token.important,
|
||||
.token.regex {
|
||||
color: #e90;
|
||||
}
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.important {
|
||||
font-weight: 400;
|
||||
}
|
||||
.token.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
// .token.entity {
|
||||
// cursor: help;
|
||||
// }
|
||||
.token.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
pre[class*="language-"]::after,
|
||||
pre[class*="language-"]::before {
|
||||
bottom: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers {
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
}
|
||||
pre[class*="language-"][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
239
server/ui-src/components/AboutMailpit.vue
Normal file
239
server/ui-src/components/AboutMailpit.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: 'auto',
|
||||
icon: 'circle-half',
|
||||
icons: {
|
||||
'auto': 'circle-half',
|
||||
'light': 'sun-fill',
|
||||
'dark': 'moon-stars-fill'
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme(this.getPreferredTheme())
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo: function () {
|
||||
let self = this
|
||||
self.get(self.resolve('/api/v1/info'), false, function (response) {
|
||||
mailbox.appInfo = response.data
|
||||
self.modal('AppInfoModal').show()
|
||||
})
|
||||
},
|
||||
|
||||
getStoredTheme: function () {
|
||||
let theme = localStorage.getItem('theme')
|
||||
if (!theme) {
|
||||
theme = 'auto'
|
||||
}
|
||||
|
||||
return theme
|
||||
},
|
||||
|
||||
setStoredTheme: function (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
this.setTheme(theme)
|
||||
},
|
||||
|
||||
getPreferredTheme: function () {
|
||||
const storedTheme = this.getStoredTheme()
|
||||
if (storedTheme) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
},
|
||||
|
||||
setTheme: function (theme) {
|
||||
this.icon = this.icons[theme]
|
||||
this.theme = theme
|
||||
if (
|
||||
theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function () {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notification")
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
let self = this
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
|
||||
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
|
||||
<i :class="'bi bi-' + icon + ' my-1'"></i>
|
||||
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
|
||||
<i class="bi bi-sun-fill me-2 opacity-50"></i>
|
||||
Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
|
||||
<i class="bi bi-moon-stars-fill me-2 opacity-50"></i>
|
||||
Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
|
||||
<i class="bi bi-circle-half me-2 opacity-50"></i>
|
||||
Auto
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" v-if="mailbox.appInfo">
|
||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
v-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
</a>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<RouterLink to="/api/v1/" class="btn btn-primary w-100" target="_blank">
|
||||
<i class="bi bi-braces"></i>
|
||||
OpenAPI / Swagger API documentation
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" target="_blank">
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit/wiki"
|
||||
target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">Database size</div>
|
||||
<div class="card-body text-secondary">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} </h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">RAM usage</div>
|
||||
<div class="card-body text-secondary">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.Memory) }} </h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="requestNotifications">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
17
server/ui-src/components/AjaxLoader.vue
Normal file
17
server/ui-src/components/AjaxLoader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Number,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader" v-if="loading > 0">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
172
server/ui-src/components/ListMessages.vue
Normal file
172
server/ui-src/components/ListMessages.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
CommonMixins
|
||||
],
|
||||
|
||||
props: {
|
||||
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
past: "%s ago",
|
||||
s: 'seconds',
|
||||
ss: '%d secs',
|
||||
m: "a minute",
|
||||
mm: "%d mins",
|
||||
h: "an hour",
|
||||
hh: "%d hours",
|
||||
d: "a day",
|
||||
dd: "%d days",
|
||||
w: "a week",
|
||||
ww: "%d weeks",
|
||||
M: "a month",
|
||||
MM: "%d months",
|
||||
y: "a year",
|
||||
yy: "%d years"
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
getRelativeCreated: function (message) {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString()
|
||||
},
|
||||
|
||||
getPrimaryEmailTo: function (message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
},
|
||||
|
||||
isSelected: function (id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
|
||||
toggleSelected: function (e, id) {
|
||||
e.preventDefault()
|
||||
|
||||
if (this.isSelected(id)) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
} else {
|
||||
mailbox.selected.push(id)
|
||||
}
|
||||
},
|
||||
|
||||
selectRange: function (e, id) {
|
||||
e.preventDefault()
|
||||
|
||||
let selecting = false
|
||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
||||
if (lastSelected == id) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (lastSelected === false) {
|
||||
mailbox.selected.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
for (let d of mailbox.messages) {
|
||||
if (selecting) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
}
|
||||
if (d.ID == lastSelected || d.ID == id) {
|
||||
// reached backwards select
|
||||
break
|
||||
}
|
||||
} else if (d.ID == id || d.ID == lastSelected) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
}
|
||||
selecting = true
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" :id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)">
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-lg-block privacy">
|
||||
<b v-if="message.From" :title="message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</b>
|
||||
</div>
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
|
||||
<div>
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<span v-if="loadingMessages > 0" class="text-secondary">
|
||||
Loading messages...
|
||||
</span>
|
||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
139
server/ui-src/components/NavMailbox.vue
Normal file
139
server/ui-src/components/NavMailbox.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from "./AjaxLoader.vue"
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadInbox: function () {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
},
|
||||
|
||||
|
||||
loadMessages: function () {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
markAllRead: function () {
|
||||
let self = this
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
deleteAllMessages: function () {
|
||||
let self = this
|
||||
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
|
||||
pagination.start = 0
|
||||
self.loadMessages()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.unread) }}
|
||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
103
server/ui-src/components/NavSearch.vue
Normal file
103
server/ui-src/components/NavSearch.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
deleteAllMessages: function () {
|
||||
let s = this.getSearch()
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
let self = this
|
||||
|
||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
this.delete(uri, false, function (response) {
|
||||
self.$router.push('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span> matching
|
||||
<code>{{ getSearch() }}</code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
120
server/ui-src/components/NavSelected.vue
Normal file
120
server/ui-src/components/NavSelected.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead: function () {
|
||||
let self = this
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
isSelected: function (id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread: function () {
|
||||
let self = this
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages: function () {
|
||||
let ids = []
|
||||
let self = this
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
||||
if (!ids.length) {
|
||||
return false
|
||||
}
|
||||
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread: function () {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead: function () {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||
v-on:click="markSelectedRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||
v-on:click="markSelectedUnread">
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
55
server/ui-src/components/NavTags.vue
Normal file
55
server/ui-src/components/NavTags.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
inSearch: function (tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
if (!query) {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`\\b[^\-!]tag:"?${tag}"?\\b`, 'i')
|
||||
return query.match(re)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
|
||||
<template v-if="mailbox.showTagColors">Hide</template>
|
||||
<template v-else>Show</template>
|
||||
tag colors
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-5 pb-3">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
{{ tag }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
176
server/ui-src/components/Notifications.vue
Normal file
176
server/ui-src/components/Notifications.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { Toast } from 'bootstrap'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
pauseNotifications: false, // prevent spamming
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
||||
|
||||
this.connect()
|
||||
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let ws = new WebSocket(this.socketURI)
|
||||
let self = this
|
||||
ws.onmessage = function (e) {
|
||||
let response = JSON.parse(e.data)
|
||||
if (!response) {
|
||||
return
|
||||
}
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (!mailbox.searching) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(response.Data)
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
}
|
||||
}
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort()
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!self.pauseNotifications) {
|
||||
self.pauseNotifications = true
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
||||
self.browserNotify("New mail from: " + from, response.Data.Subject)
|
||||
self.setMessageToast(response.Data)
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
|
||||
}
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
mailbox.unread = response.Data.Unread
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
mailbox.connected = true
|
||||
if (self.reconnectRefresh) {
|
||||
self.reconnectRefresh = false
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = function (e) {
|
||||
mailbox.connected = false
|
||||
self.reconnectRefresh = true
|
||||
|
||||
setTimeout(function () {
|
||||
self.connect() // reconnect
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
|
||||
browserNotify: function (title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let b = message.Subject
|
||||
let options = {
|
||||
body: message,
|
||||
icon: this.resolve('/notification.png')
|
||||
}
|
||||
new Notification(title, options)
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast: function (m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toastMessage = m
|
||||
|
||||
let self = this
|
||||
let el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
el.addEventListener('hidden.bs.toast', () => {
|
||||
self.toastMessage = false
|
||||
})
|
||||
|
||||
Toast.getOrCreateInstance(el).show()
|
||||
}
|
||||
},
|
||||
|
||||
closeToast: function () {
|
||||
let el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast">
|
||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else>
|
||||
[ no subject ]
|
||||
</template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
92
server/ui-src/components/Pagination.vue
Normal file
92
server/ui-src/components/Pagination.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: Number,
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev: function () {
|
||||
return pagination.start > 0
|
||||
},
|
||||
|
||||
canNext: function () {
|
||||
return this.total > (pagination.start + mailbox.messages.length)
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages: function () {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
||||
if (t > this.total) {
|
||||
t = this.total
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit: function () {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
viewNext: function () {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
viewPrev: function () {
|
||||
let s = pagination.start - pagination.limit
|
||||
if (s < 0) {
|
||||
s = 0
|
||||
}
|
||||
pagination.start = s
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
||||
64
server/ui-src/components/SearchForm.vue
Normal file
64
server/ui-src/components/SearchForm.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
pagination
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.searchFromURL()
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
this.searchFromURL()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
searchFromURL: function () {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
this.search = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
},
|
||||
|
||||
doSearch: function (e) {
|
||||
pagination.start = 0
|
||||
if (this.search == '') {
|
||||
this.$router.push('/')
|
||||
} else {
|
||||
this.$router.push('/search?q=' + encodeURIComponent(this.search))
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
},
|
||||
|
||||
resetSearch: function () {
|
||||
this.search = ''
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-on:submit="doSearch">
|
||||
<div class="input-group flex-nowrap">
|
||||
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
|
||||
placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
|
||||
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user